When Counting Isn't Enough

You're debugging a piece of code and the editor underlines a line in red: "Unexpected token." You scan the function. Brackets everywhere — parentheses wrapping conditions, curly braces nesting blocks, square brackets indexing arrays. You count the openers: six. You count the closers: six. Equal counts. So why does the compiler choke?

Consider this expression:

1
const result = fn({x: arr[i)]}

Three openers: (, {, [. Three closers: ), ], }. The counts balance perfectly. But the string is broken — [ is closed by ) instead of ], and the curly brace that opened second is closed last instead of second-to-last. The number of brackets is fine. The order is wrong.

This is a deeper problem than counting. To validate brackets, you need to know which opener is currently waiting to be closed — and there is always exactly one “most urgent” opener: the one most recently encountered that has not yet been matched. The string ({[]}) is valid only because each closer arrives just in time to meet its most recent opener.

But here's the question that makes this interesting: what happens when you have three different bracket types interleaved? ({[]}) is valid, but ({[}]) is not — even though every opener has a corresponding closer somewhere in the string. The somewhere is the problem. A closer must pair with the most recent unmatched opener of its type. A } can't just match any { — it must match the { that was opened most recently and hasn't been closed yet.

So how would you build a system that, at any moment, always knows the most recent unmatched opener — and forgets it the instant it gets closed? You'll need a structure that grows when openers arrive and shrinks when closers match. Let's see what that looks like in motion.

Pushing and Popping Brackets

Below is a bracket string. Process it character by character: push opening brackets onto the stack, and for each closing bracket, predict what should be on top before checking. Pay close attention to the mismatch — it reveals exactly what “broken nesting” looks like inside a stack.

String I — Broken Nesting
Stack

That's LIFO disciplinelast in, first out. The same principle behind push and pop you met on the previous screen. The stack made the invisible rule visible: every opening bracket waits at the top until its matching closer arrives, and the most recent opener is always the one in line to be closed first. When nesting is correct, every closer finds its match sitting exactly on top — the LIFO constraint guarantees it. When nesting is broken, the top holds the wrong opener, and the mismatch surfaces the instant it occurs. No backward scanning, no position tracking — the stack does the bookkeeping for you.

Tracing the Algorithm

You've built the right mental model: push openers, pop and match on closers. Now watch how this maps to real code. The isValid function below processes the same kind of bracket string you just worked through, but notice something subtle — the stack and the bracket string are two views of the same state. The string is the sequence of events. The stack is the memory of what's still unresolved.

As you step through "([])", pay attention to how the code transforms your physical intuition into a systematic algorithm. The pairs map replaces your brain's pattern-matching (“round goes with round, square with square”) with a constant-time lookup. The push/pop calls are the same operations you performed by hand. And the top !== pairs[ch] comparison is the exact moment you were checking “does this closer match?” — except now it's a single boolean expression instead of a visual scan.

For each closing bracket, predict what stack.pop() returns before the comparison executes. Watch the stack panel mirror exactly what you were doing manually in the previous screen.

Tracing isValid("([])")

Create an empty stack

1
function isValid(s: string): boolean {
2
  const stack: string[] = []
3
  const pairs: Record<string, string> = {
4
    ')': '(', ']': '[', '}': '{'
5
  }
6
7
  for (const ch of s) {
8
    if (ch === '(' || ch === '[' || ch === '{') {
9
      stack.push(ch)
10
    } else {
11
      const top = stack.pop()
12
      if (top !== pairs[ch]) return false
13
    }
14
  }
15
16
  return stack.length === 0
17
}

The code does precisely what you did by hand. Opening brackets get pushed. Closing brackets trigger a pop and a comparison against the expected match from the pairs map. If any comparison fails, the function returns false immediately — no need to process the rest.

Notice the structural elegance: one loop, two branches. The if branch (opener) grows the stack. The else branch (closer) shrinks it. Every character touches the stack exactly once — either a push or a pop. That's what makes the algorithm O(n): one pass through the string, one stack operation per character, no backtracking.

Building the Validator

You've traced through the algorithm. Now build it. Fill in the four blanks below — each one corresponds to a critical decision in the bracket-matching logic. Think about why each choice matters: what would break if you picked the wrong option?

1
function isValid(s: string): boolean {
2
const stack: string[] = []
3
const pairs: Record<string, string> = {
4
')': '(', ']': '[', '}': '{'
5
}
6
7
for (const ch of s) {
8
if (ch === '(' || ch === '[' || ch === '{') {
9
stack.___pushCall___(ch)
10
} else {
11
const expected = ___lookup___
12
const top = stack.pop()
13
if (___comparison___) return false
14
}
15
}
16
17
return ___returnCheck___
18
}

Four blanks, four decisions, and each one enforces a specific guarantee. push adds openers to the top. pairs[ch] looks up the expected match for a closer. top !== expected catches mismatches immediately. And that final stack.length === 0 check? It catches a subtle edge case that passes every other test: unmatched openers. Consider the string "((". Both characters are openers — the loop's else branch never triggers, so no mismatch is ever detected. The loop completes without returning false. But the string is clearly invalid. The only thing that catches it is the final emptiness check: two openers were pushed, zero were popped, so the stack has length 2. Not zero. Invalid.

Together, the four decisions validate arbitrarily deep nesting in a single O(n) pass — one character at a time, one stack operation at a time, no backtracking required.