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.

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","/","/"] -> -InfinityEleven 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.
| token | Number(token) | parseInt(token, 10) |
|---|---|---|
"12" | 12 | 12 |
"12abc" | NaN | 12 |
"1.9" | 1.9 | 1 |
"0x10" | 16 | 0 |
"" | 0 | NaN |
" 7 " | 7 | 7 |
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 0Math.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 -InfinitySo 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 stringA 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:
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 rangeNote 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 mismatchesTwo 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
