DevLift
Back to Blog

Evaluating Reverse Polish Notation Is Easy. Rejecting Bad Input Is the Job.

The stack loop that evaluates an RPN expression is fifteen lines, but it returns undefined for an empty array, NaN for a missing operand and -0 for a negative fraction, so the real work is deciding what your evaluator owes a caller who hands it nonsense.

Admin
August 18, 20269 min read2 views
Evaluating Reverse Polish Notation Is Easy. Rejecting Bad Input Is the Job.

Evaluating Reverse Polish Notation Is Easy. Rejecting Bad Input Is the Job.

["2","1","+","3","*"] is 9. Push numbers. When you hit an operator, pop two, combine them, push the answer back. When the tokens run out, whatever is left on the stack is the result. There is no precedence to resolve and no parentheses to match, because postfix notation encodes the tree in the token order — the operator arrives after both of its operands, so by the time you read it, both operands are already sitting on top of the stack waiting.

That is fifteen lines of TypeScript:

function evalRPN(tokens: string[]): number {
  const stack: number[] = [];
  for (const token of tokens) {
    switch (token) {
      case "+": { const b = stack.pop()!, a = stack.pop()!; stack.push(a + b); break; }
      case "-": { const b = stack.pop()!, a = stack.pop()!; stack.push(a - b); break; }
      case "*": { const b = stack.pop()!, a = stack.pop()!; stack.push(a * b); break; }
      case "/": { const b = stack.pop()!, a = stack.pop()!; stack.push(Math.trunc(a / b)); break; }
      default: stack.push(Number(token));
    }
  }
  return stack.pop()!;
}

The only thing in there worth a second look is the pop order. The first value you pop is the right operand, because it went on last. Get that backwards and addition and multiplication still pass every test you try, while subtraction and division quietly return the wrong sign. It fits in a sentence, and it is the last easy thing here.

So this is where most write-ups stop, hand you a complexity table, and call it solved. But a function that only works on well-formed input is not a function, it is a demo. Everything interesting about an expression evaluator is what it does with an expression that isn't one.

The fifteen lines lie about their return type

Here is that same function against eleven inputs that a real caller will eventually hand it.

const probes = [
  [], ["42"], ["+"], ["1", "+"], ["1", "2"],
  ["1", "0", "/"], ["1", "abc", "+"], ["12abc", "1", "+"],
  ["1.9", "1", "+"], ["3", "-4", "/"], ["1", "3", "-4", "/", "/"],
];
for (const p of probes) {
  let out;
  try { const r = evalRPN(p); out = Object.is(r, -0) ? "-0" : String(r); }
  catch (e) { out = e.name + ": " + e.message; }
  console.log(JSON.stringify(p).padEnd(28), "->", out);
}
[]                           -> undefined
["42"]                       -> 42
["+"]                        -> NaN
["1","+"]                    -> NaN
["1","2"]                    -> 2
["1","0","/"]                -> Infinity
["1","abc","+"]              -> NaN
["12abc","1","+"]            -> NaN
["1.9","1","+"]              -> 2.9
["3","-4","/"]               -> -0
["1","3","-4","/","/"]       -> -Infinity

Eleven inputs, one exception, zero diagnostics. The signature says number and the first row returns undefined. ["1","2"] is two operands and no operator — a syntax error in any notation — and it returns 2 with total confidence. ["1.9","1","+"] returns 2.9 from a function that is supposed to do integer arithmetic. And every NaN in that list will propagate silently through however many layers of your application sit above this call, surfacing eventually as a blank cell in a report that someone spends an afternoon tracing.

⚠️

NaN is not an error value. It compares unequal to itself, it survives every arithmetic operation, it serialises to null in JSON, and nothing in the language will stop it. If your evaluator can return NaN, it has no error handling — it has a slow leak.

Two tokenising functions, opposite failure modes

The default branch above uses Number. Plenty of solutions use parseInt(token, 10) instead, and the difference is not stylistic.

tokenNumber(token)parseInt(token, 10)
"12"1212
"12abc"NaN12
"1.9"1.91
"0x10"160
""0NaN
" 7 "77

parseInt is a prefix scanner. It reads as many characters as it understands and throws the rest away, which means it never tells you that the token was malformed — "12abc" becomes 12 and "1.9" becomes 1. Number at least raises a flag, though the flag is NaN, and Number("") is 0, which is its own small betrayal.

If you have seen the claim that parseInt is the safer choice "in case there is trailing garbage", it is exactly inverted. parseInt is the one that swallows the garbage. Neither function is validation. A regex is validation.

Truncation, and the two ways of getting it wrong

LeetCode 150 specifies that division truncates toward zero: 7 / 2 is 3, and -7 / 2 is -3, not -4. JavaScript has no integer division, so you have to pick a rounding function, and two of the three popular answers are wrong.

const pairs = [[7, 2], [-7, 2], [7, -2], [3, -4], [4294967296, 1], [1e15, 1], [1, 0]];
const fmt = (n) => (Object.is(n, -0) ? "-0" : String(n));
console.log("a / b".padEnd(20), "Math.trunc".padEnd(14), "Math.floor".padEnd(14), "| 0");
for (const [a, b] of pairs) {
  const label = a + " / " + b;
  console.log(
    label.padEnd(20),
    fmt(Math.trunc(a / b)).padEnd(14),
    fmt(Math.floor(a / b)).padEnd(14),
    fmt((a / b) | 0)
  );
}
a / b                Math.trunc     Math.floor     | 0
7 / 2                3              3              3
-7 / 2               -3             -4             -3
7 / -2               -3             -4             -3
3 / -4               -0             -1             0
4294967296 / 1       4294967296     4294967296     0
1000000000000000 / 1 1000000000000000 1000000000000000 -1530494976
1 / 0                Infinity       Infinity       0

Math.floor rounds toward negative infinity, so it is off by one on every negative quotient that isn't already an integer. That one is well known.

(a / b) | 0 is the interesting failure. It looks clever and it truncates correctly for small numbers, but | is a bitwise operator, so its operand goes through ToInt32 first: the value is wrapped into 32 bits before you ever see it. 4294967296 | 0 is 0. 1e15 | 0 is -1530494976. And Infinity | 0 is 0, which means dividing by zero produces a perfectly plausible integer instead of an error. Of the three, that is the one failure mode you cannot detect downstream. LeetCode's own constraints keep every intermediate value inside 32 bits, so | 0 passes there — but the moment the same code evaluates an expression that isn't from LeetCode, it starts inventing answers.

Which leaves Math.trunc, and even that has a wrinkle: Math.trunc(3 / -4) is -0, not 0. That looks harmless, -0 === 0 is true, and it prints as 0. Then someone divides by it:

console.log(1 / 0, 1 / -0);   // Infinity -Infinity

So an expression whose true value is 0 propagates a sign that flips a later division's result from Infinity to -Infinity. Adding 0 to the quotient normalises it away, and Math.trunc(a / b) + 0 is the entire remedy.

💡

-0 has bitten this problem in published solutions because every obvious check misses it. x === 0 is true, x == 0 is true, JSON.stringify(x) is "0", and String(x) is "0". Only Object.is(x, -0) and 1 / x can see it.

The operator lookup that accepts toString

Replacing the switch with a lookup table reads better and makes adding % a one-line change:

const OPERATOR_TABLE: Record<string, (a: number, b: number) => number> = {
  "+": (a, b) => a + b,
  "-": (a, b) => a - b,
  "*": (a, b) => a * b,
  "/": (a, b) => Math.trunc(a / b) + 0,
};
 
function evalRPNTable(tokens: string[]): number {
  const stack: number[] = [];
  for (const token of tokens) {
    if (token in OPERATOR_TABLE) {          // <-- the bug
      const b = stack.pop()!, a = stack.pop()!;
      stack.push(OPERATOR_TABLE[token](a, b));
    } else {
      stack.push(Number(token));
    }
  }
  return stack.pop()!;
}

in does not ask whether the object has the key. It asks whether anything in the prototype chain has it, and OPERATOR_TABLE is a plain object literal, so it inherits every Object.prototype member:

console.log("toString" in OPERATOR_TABLE);      // true
console.log("constructor" in OPERATOR_TABLE);   // true
console.log(evalRPNTable(["1", "2", "toString"]));   // [object Object]  <- a string

A function annotated : number returned a string, because a token called toString was treated as an operator and Object.prototype.toString was invoked as one. ["1","2","__proto__"] is worse — the inherited value is an accessor, not a function, so it throws TypeError: OPERATOR_TABLE[token] is not a function from a line that looks like it is doing arithmetic.

Use Object.hasOwn(OPERATOR_TABLE, token), or build the table with Object.create(null), or use a Map. The table pattern is fine; in is the part that isn't.

Why does `token in OPERATOR_TABLE` return true for the token 'valueOf'?

Deciding what a bad expression gets

Now the actual design question. Four options, and the choice depends entirely on who is calling.

Return a sentinel. null, or NaN, or -1. Cheap, and it forces every call site to check — except call sites don't, and NaN in particular is indistinguishable from a real arithmetic result until it reaches a user. Reasonable only when the caller genuinely cannot act on the reason.

Return a result object. { ok: true, value } | { ok: false, error }. The type system makes the check unskippable, which is the right shape for a public API or anything crossing a network boundary. It is also noisy in a hot loop and awkward to compose recursively.

Throw. The expression was structurally invalid, which is a programming error or a bad input document, not an expected branch. An exception carrying the token index is the most useful thing you can hand someone debugging a 400-token expression, and it cannot be ignored by accident.

Validate the whole token list first, then evaluate. Two passes, and it lets you report every problem at once instead of the first. Worth it for a linter or an editor; overkill inside an interpreter loop that is going to abort on the first error anyway.

For a single-expression evaluator I throw, with a typed error and a token index. Here is the flow for one token, including every edge that ends in a rejection:

Rendering diagram...

The four reject boxes are the article. The happy path down the middle was the fifteen lines at the top.

class RpnError extends Error {
  index: number;
  constructor(message: string, index: number) {
    super(message);
    this.name = "RpnError";
    this.index = index;
  }
}
 
const ARITY_2 = new Set(["+", "-", "*", "/"]);
const INTEGER = /^[+-]?\d+$/;
 
function toInteger(token: string, index: number): number {
  if (!INTEGER.test(token)) {
    throw new RpnError(`not an integer token: ${JSON.stringify(token)}`, index);
  }
  const value = Number(token);
  if (!Number.isSafeInteger(value)) {
    throw new RpnError(`token out of safe integer range: ${token}`, index);
  }
  return value;
}
 
function apply(op: string, a: number, b: number, index: number): number {
  switch (op) {
    case "+": return a + b;
    case "-": return a - b;
    case "*": return a * b;
    case "/":
      if (b === 0) throw new RpnError("division by zero", index);
      return Math.trunc(a / b) + 0;              // + 0 normalises -0 to 0
    default: throw new RpnError(`unknown operator: ${op}`, index);
  }
}
 
function evalRPNStrict(tokens: string[]): number {
  if (!Array.isArray(tokens) || tokens.length === 0) {
    throw new RpnError("empty expression", 0);
  }
  const stack: number[] = [];
  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    if (typeof token !== "string") throw new RpnError(`token ${i} is not a string`, i);
 
    if (ARITY_2.has(token)) {
      if (stack.length < 2) {
        throw new RpnError(
          `operator "${token}" needs 2 operands, stack has ${stack.length}`, i);
      }
      const b = stack.pop()!;
      const a = stack.pop()!;
      const result = apply(token, a, b, i);
      if (!Number.isSafeInteger(result)) {
        throw new RpnError(`result ${result} left the safe integer range`, i);
      }
      stack.push(result);
    } else {
      stack.push(toInteger(token, i));
    }
  }
  if (stack.length !== 1) {
    throw new RpnError(
      `expression left ${stack.length} values on the stack, expected 1`, tokens.length - 1);
  }
  return stack[0];
}

A Set instead of in. A regex before Number, so "12abc" and "1.9" and "0x10" are all refused instead of coerced. Number.isSafeInteger on both the operands and every intermediate result, because above 2^53 the article's arithmetic stops being integer arithmetic and starts being approximate — and the caller deserves to hear that rather than receive a number that is off by 40. stack.length !== 1 at the end, which is the check that catches ["1","2"]. And stack[0] is only safe on that last line because of the preceding check; with pop() and no check you get the top of a stack you never validated.

Same eleven probes, plus six more:

[]                             -> RpnError @0: empty expression
["42"]                         -> 42
["+"]                          -> RpnError @0: operator "+" needs 2 operands, stack has 0
["1","+"]                      -> RpnError @1: operator "+" needs 2 operands, stack has 1
["1","2"]                      -> RpnError @1: expression left 2 values on the stack, expected 1
["1","0","/"]                  -> RpnError @2: division by zero
["1","abc","+"]                -> RpnError @1: not an integer token: "abc"
["12abc","1","+"]              -> RpnError @0: not an integer token: "12abc"
["1.9","1","+"]                -> RpnError @0: not an integer token: "1.9"
["3","-4","/"]                 -> 0
["1","3","-4","/","/"]         -> RpnError @4: division by zero
["+0","-0","+"]                -> 0
["-3","1","+"]                 -> -2
["1","2","toString"]           -> RpnError @2: not an integer token: "toString"
["1","2","__proto__"]          -> RpnError @2: not an integer token: "__proto__"
["2147483647","2","*"]         -> 4294967294
["9007199254740991","2","*"]   -> RpnError @2: result 18014398509481982 left the safe integer range

Note the eleventh line. 1 3 -4 / / used to return -Infinity because 3 / -4 truncated to -0 and then divided into 1. Normalising the -0 turned it into the division-by-zero it always was.

"-3" as a token is a negative literal; "-" as a token is subtraction. Your integer regex has to admit a leading sign and your operator set has to be checked first, or ["-3","1","+"] will parse the minus as an operator against an empty stack. Checking the operator set first works because no valid operator is also a valid integer.

Proving it, without trusting yourself

Hand-picked cases only find the bugs you already suspect. The way to find the rest is to generate thousands of valid expressions and check them against a second implementation you wrote differently on purpose — mine walks the token list backwards rebuilding the tree, in BigInt, so nothing rounds and / truncates toward zero for free.

const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
 
class DivByZero extends Error {}
 
function referenceEval(tokens) {
  let pos = tokens.length - 1;
  const read = () => {
    const t = tokens[pos--];
    if (t === "+" || t === "-" || t === "*" || t === "/") {
      const right = read();
      const left = read();
      if (t === "+") return left + right;
      if (t === "-") return left - right;
      if (t === "*") return left * right;
      if (right === 0n) throw new DivByZero();  // unwinds past every enclosing node
      return left / right;                   // BigInt division truncates toward zero
    }
    return BigInt(t);
  };
  const value = read();
  assert(pos === -1, "reference: leftover tokens");
  return value;
}
 
let seed = 20260917;
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const pick = (n) => Math.floor(rand() * n);
 
function randomExpression(depth, bound) {
  if (depth === 0 || rand() < 0.35) return [String(pick(2 * bound + 1) - bound)];
  const op = ["+", "-", "*", "/"][pick(4)];
  return [...randomExpression(depth - 1, bound), ...randomExpression(depth - 1, bound), op];
}
 
let compared = 0, refused = 0, mismatched = 0;
for (let i = 0; i < 20000; i++) {
  const tokens = randomExpression(1 + pick(5), [9, 100, 10000, 50000][i % 4]);
 
  let expected;
  try { expected = referenceEval(tokens); }
  catch (e) { assert(e instanceof DivByZero, "reference blew up: " + e); refused++; continue; }
 
  let actual;
  try { actual = evalRPNStrict(tokens); }
  catch (e) { assert(e instanceof RpnError, "unexpected error type: " + e); refused++; continue; }
 
  compared++;
  if (BigInt(actual) !== expected) {
    mismatched++;
    if (mismatched < 4) console.log("MISMATCH", JSON.stringify(tokens), actual, expected);
  }
}
console.log(`compared ${compared} expressions, ${refused} refused, ${mismatched} mismatches`);
assert(mismatched === 0, "differential test failed");
compared 18714 expressions, 1286 refused, 0 mismatches

Two things about that harness. The assert throws. Node's console.assert writes a line to stderr and lets the loop continue, which makes it useless as a test oracle — the process still exits 0. The 1286 refusals are not skipped failures: every one of them is a division by zero or a value outside Number.isSafeInteger, and every one came back as an RpnError rather than a number. A differential test that only counts matches will happily give you a green light on a function that returns undefined for a third of its inputs.

Point the same harness at the fifteen-line version from the top of this post and it compares 18,909 expressions and disagrees on 1,049 of them. Every single disagreement is Object.is(result, -0). One missing + 0, and 5.5% of a randomised corpus comes back with a sign on a zero.

A differential test reports 18,714 compared and 0 mismatches for your RPN evaluator. What has it actually established?

What is your evaluator's contract?

The stack part of this problem is a fifteen-minute exercise, and once you have written it you have written it forever. The part that separates a demo from something you would put behind an API is the list of decisions underneath: which tokeniser, which rounding function, whether -0 is normalised, whether overflow is an error or a rounding artefact you accept, and what the caller receives when the expression is nonsense.

So before you write the next one: if someone hands your evaluator ["1", "2"], what should come back — and can the caller tell the difference between that and a correct answer of 2?

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Union Find: The Structure That Only Answers "Same Group?"
Union Find trades every graph question except one for speed, and measured tree heights show exactly what path compression and union by rank each buy you — with tested solutions to Redundant Connection, Number of Connected Components, and Accounts Merge.
AdminAugust 6, 20268 min read
A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The Two Sum problem is the "Hello World" of hash map problems. Most people solve it, move on, and treat it as a warmup. That's a mistake. The pattern behind Two Sum — trading space for lookup speed — shows up in dozens of harder problems.
AdminJuly 27, 20265 min read