Building a Stack in JavaScript

1

Building a Stack in JavaScript

A stack is one of the most fundamental data structures in computer science. It operates on a simple but powerful principle: Last In, First Out (LIFO). The last element you put onto the stack is the first one you get back. Think of a stack of dinner plates — you add a plate to the top, and when you need one, you take it from the top. You never reach into the middle or pull from the bottom. In JavaScript, we can model this behavior elegantly using a class that wraps a native array and exposes only the operations that make sense for a stack. This walkthrough covers every step of that implementation, from setting up the class to testing edge cases.

Setting Up the Stack Class

The foundation of the implementation is a JavaScript class named Stack. Inside its constructor we initialize a single instance property — an empty array — that will serve as the internal storage for all stack elements.

class Stack {
  constructor() {
    this.items = [];
  }
}

Why an array? JavaScript arrays already provide efficient push and pop operations at their tail end (the highest index), which maps perfectly onto stack semantics: the end of the array will represent the top of the stack. The beginning of the array (index 0) holds the bottom-most element — the first one ever pushed.

The critical design principle here is encapsulation. Consumers of the Stack class should interact with it only through its defined methods, not by reaching directly into this.items and splicing, sorting, or otherwise manipulating it. Keeping all access to the array channeled through push, pop, and peek preserves the LIFO contract. In production-quality code you might enforce this more strictly by using a JavaScript private field (prefixed with #), like #items = [], but for clarity in learning, a conventional public property named items works well — just treat it as private by convention.

Implementing the Push Method

The push(element) method adds a new value to the top of the stack. Because the top of the stack corresponds to the end of the internal array, we simply call the native Array.prototype.push method on this.items.

class Stack {
  constructor() {
    this.items = [];
  }

  push(element) {
    this.items.push(element);
  }
}

Each call to push increases the length of this.items by one. The newly added element sits at the highest index and is therefore considered the top of the stack. Because element is typed as a plain JavaScript parameter with no type annotation, the method accepts any valid JavaScript value — numbers, strings, objects, arrays, even other stacks. This flexibility makes the class reusable across many different problems.

For example:

const stack = new Stack();
stack.push(10);    // items: [10]
stack.push(20);    // items: [10, 20]
stack.push(30);    // items: [10, 20, 30]  <-- 30 is now the top

Implementing the Pop Method

The pop() method removes the element at the top of the stack and returns it to the caller. Again, since the top is the last element of the array, the native Array.prototype.pop does exactly what we need.

pop() {
  if (this.isEmpty()) {
    return "Stack is empty — nothing to pop.";
  }
  return this.items.pop();
}

The empty-stack check is essential. Calling Array.prototype.pop on an empty array returns undefined rather than throwing an error, which can hide bugs in the calling code. By explicitly checking emptiness first, we return a descriptive message (or, in stricter designs, throw a custom Error). This makes it immediately obvious when consuming code calls pop incorrectly.

After a pop, the element that was second from the top becomes the new top. Consider the example above — if we pop from [10, 20, 30], we get 30 back and the array becomes [10, 20], making 20 the new top.

console.log(stack.pop()); // 30  →  items: [10, 20]
console.log(stack.pop()); // 20  →  items: [10]
console.log(stack.pop()); // 10  →  items: []
console.log(stack.pop()); // "Stack is empty — nothing to pop."

Implementing the Peek Method

The peek() method lets you look at the top element without removing it. It accesses the last index of the internal array by calculating this.items.length - 1.

peek() {
  if (this.isEmpty()) {
    return "Stack is empty — nothing to peek at.";
  }
  return this.items[this.items.length - 1];
}

The crucial distinction between peek and pop is that peek has no side effects. The array is not modified, the length does not change, and the element remains at the top. This makes peek safe to call freely in conditionals, loops, and any situation where you need to inspect the top before deciding what to do next. A common real-world scenario is checking whether a closing parenthesis on a stack matches an opening one — you peek first, and only pop if they match.

const stack = new Stack();
stack.push("a");
stack.push("b");
stack.push("c");

console.log(stack.peek()); // "c"  →  items still: ["a", "b", "c"]
console.log(stack.peek()); // "c"  →  unchanged
stack.pop();
console.log(stack.peek()); // "b"

Implementing isEmpty and size Helper Methods

Two small helper methods dramatically improve the usability of the stack and help prevent bugs in consuming code.

isEmpty() {
  return this.items.length === 0;
}

size() {
  return this.items.length;
}

isEmpty() returns a boolean — true when the stack contains no elements, false otherwise. It is used internally inside pop and peek and can also be used externally in loops: "keep popping while the stack is not empty."

size() returns the exact count of elements currently in the stack. This is valuable when you need to allocate resources, iterate a fixed number of times, or simply report the depth of the stack to the user. Using size() rather than accessing this.items.length directly outside the class respects encapsulation — if you ever change the internal storage (say, from an array to a linked list), the public interface stays the same.

const stack = new Stack();
console.log(stack.isEmpty()); // true
console.log(stack.size());    // 0

stack.push(42);
stack.push(99);
console.log(stack.isEmpty()); // false
console.log(stack.size());    // 2

Implementing a Print or toString Method

During development and debugging it is invaluable to see the entire contents of the stack at a glance. A print() method (or an equivalent toString() override) provides that view.

print() {
  console.log(this.items.toString());
}

// Alternatively, return a formatted string:
toString() {
  return `Stack (bottom → top): [${this.items.join(", ")}]`;
}

When you call print() on a stack containing [10, 20, 30], you see 10,20,30 in the console. Remember the convention: the rightmost element is the top. If you use toString() with a descriptive label, the output becomes self-documenting:

const stack = new Stack();
stack.push(10);
stack.push(20);
stack.push(30);
console.log(stack.toString());
// Stack (bottom → top): [10, 20, 30]

This visual confirmation is especially useful when verifying that a series of pushes and pops produced the expected ordering.

The Complete Stack Class

Putting all the methods together gives us a clean, fully functional stack implementation:

class Stack {
  constructor() {
    this.items = [];
  }

  push(element) {
    this.items.push(element);
  }

  pop() {
    if (this.isEmpty()) {
      return "Stack is empty — nothing to pop.";
    }
    return this.items.pop();
  }

  peek() {
    if (this.isEmpty()) {
      return "Stack is empty — nothing to peek at.";
    }
    return this.items[this.items.length - 1];
  }

  isEmpty() {
    return this.items.length === 0;
  }

  size() {
    return this.items.length;
  }

  print() {
    console.log(this.items.toString());
  }

  toString() {
    return `Stack (bottom → top): [${this.items.join(", ")}]`;
  }
}

Testing the Stack Implementation

Testing should cover both the happy path (normal operations) and edge cases (operations on an empty stack). The table below maps each test scenario to the expected output and the method being verified.

Step Operation State of Internal Array Return Value / Output Method Verified
1 new Stack() [] constructor
2 isEmpty() [] true isEmpty
3 push(5) [5] push
4 push(10) [5, 10] push
5 push(15) [5, 10, 15] push
6 size() [5, 10, 15] 3 size
7 peek() [5, 10, 15] 15 peek
8 pop() [5, 10] 15 pop
9 pop() [5] 10 pop
10 pop() [] 5 pop
11 pop() (empty) [] "Stack is empty — nothing to pop." pop (edge case)
12 peek() (empty) [] "Stack is empty — nothing to peek at." peek (edge case)
13 isEmpty() [] true isEmpty

Here is the complete test script you can run in any JavaScript environment:

const stack = new Stack();

// isEmpty on a brand-new stack
console.log(stack.isEmpty()); // true

// Push three values
stack.push(5);
stack.push(10);
stack.push(15);

// Inspect size and top
console.log(stack.size());    // 3
console.log(stack.peek());    // 15
console.log(stack.toString()); // Stack (bottom → top): [5, 10, 15]

// Pop each element — verify LIFO order
console.log(stack.pop()); // 15
console.log(stack.pop()); // 10
console.log(stack.pop()); // 5

// Edge cases
console.log(stack.pop());  // "Stack is empty — nothing to pop."
console.log(stack.peek()); // "Stack is empty — nothing to peek at."
console.log(stack.isEmpty()); // true

Notice that elements come back in reverse order of insertion: 15, then 10, then 5. This is LIFO in action — the last item pushed (15) is the first item popped. By comparing each logged value against the expected output in the table above, you build concrete confidence that every method works as intended. Edge-case testing — calling pop and peek on an empty stack — confirms that the guard clauses are functioning and that the implementation fails gracefully rather than silently returning undefined.

With all six methods in place and tested, the Stack class is ready to serve as a building block for more advanced algorithms: evaluating mathematical expressions, implementing undo/redo functionality, traversing trees iteratively, or solving puzzles like the Tower of Hanoi. Every one of those applications rests on the same simple contract you just implemented and verified.

NotesStudents should be encouraged to run the complete test script in a browser console or Node.js REPL to see the output live. A natural extension exercise is to rewrite the class using a private field (<code>#items = []</code>) to enforce true encapsulation, and to explore how the stack could be used to reverse a string or check for balanced parentheses.