How (and Why) To Implement a Stack in JavaScript

March 11th, 2020

  1. What's a Stack?
  2. Implementing a Basic Stack
  3. Preventing Stack Underflows & Overflows
  4. Why Would We Want to Use a Stack?

# What's a Stack?

In computer science, a stack is a data structure, specifically an abstract data type. It's a type of collection (meaning a list of items, similar to an array). What makes a stack distinct is that it's constrained by specific rules governing how items can be added and removed.

A stack only allows items to be added to, or removed from, one end of the list (the top of the stack). This is known as Last In, First Out. Items are added with a push() operation and removed with a pop() operation.

Think of it like a stack of pancakes:

You can push a pancake onto the top end of the stack...

...and you can pop a pancake off of the top end of the stack...

...but you can't add pancakes to, or remove pancakes from, the middle of the stack or the bottom end of the stack. Otherwise they'll go flying.

# Implementing a Basic Stack

In its most basic implementation, a stack has to keep track of two internal variables:

  1. A number representing the size of the stack, and
  2. A hash table (in other words, an object) representing the data in the list.

To begin implementing our stack, we'll need to set these:

1function Stack () {
2 this.size = 0;
3 this.data = {};
4}

Implementing .push()

Because the hash table is zero-indexed, the size value is always one greater than the last value that was added to the hash table. Whenever we push a new value onto the hash table, we'll add the data to the hash table, keyed by current size, and then increment the size value.

1function Stack () {
2 this.size = 0;
3 this.data = {};
4
5 // Add a value to the top of the stack
6 this.push = function (value) {
7 this.data[this.size] = value;
8 this.size++;
9 }
10}

Now, we can push values onto the stack, and view its size:

1let stackOfOnes = new Stack();
2stackOfOnes.push(1);
3stackOfOnes.push(1);
4stackOfOnes.push(1);
5console.log(stackOfOnes.size); // 3

Implementing .pop()

To pop off the last value, we access it from the hash table using the size value to determine its key, delete it from the hash table, decrement the size value, and return the retrieved value.

1function Stack () {
2 this.size = 0;
3 this.data = {};
4
5 // Add a value to the top of the stack
6 this.push = function (value) {
7 this.data[this.size] = value;
8 this.size++;
9 }
10
11 // Remove a value from the top of the stack, and return it
12 this.pop = function() {
13 let lastKey = this.size - 1;
14 let result = this.data[lastKey];
15 delete this.data[lastKey];
16 this.size--;
17 return result;
18 }
19}

Now, we've got a basic functional stack: we can push values onto the stack, pop them off the stack, and view its size.

1let fruitStack = new Stack();
2fruitStack.push('apple');
3fruitStack.push('banana');
4fruitStack.push('orange');
5console.log(fruitStack.size); // 3
6let lastFruit = fruitStack.pop();
7console.log(lastFruit); // 'orange'
8console.log(fruitStack.size); // 2

# Preventing Stack Underflows & Overflows

Now, you're probably already starting to realize that we could run into some issues here. What happens, for example, if we try to to .pop() a value off an empty stack?

Attempting to pop an empty stack is called a stack underflow. You've probably also heard of a stack overflow, which is when a stack's size exceeds a certain limit. Stacks usually set a predetermined bound in order to prevent infinite-loop bugs that attempt to push items onto the stack over and over indefinitely.

To make our stack more resilient, let's add some guardrails against underflows and overflows.

First, we'll add a check in .pop() to ensure we aren't popping an empty stack:

1function Stack () {
2 this.size = 0;
3 this.data = {};
4
5 // Add a value to the top of the stack
6 this.push = function (value) {
7 this.data[this.size] = value;
8 this.size++;
9 }
10
11 // Remove a value from the top of the stack, and return it
12 this.pop = function() {
13 if (this.size === 0) {
14 console.log(`Stack underflow!`);
15 return;
16 }
17 let lastKey = this.size - 1;
18 let result = this.data[lastKey];
19 delete this.data[lastKey];
20 this.size--;
21 return result;
22 }
23}

Next, we'll set an internal bound variable when the stack is created, and add a check in .push() to ensure we aren't exceeding that bound.

1function Stack (bound = 10) {
2 this.size = 0;
3 this.bound = bound;
4 this.data = {};
5
6 // Add a value to the top of the stack
7 this.push = function (value) {
8 if (this.size >= this.bound) {
9 console.log(`Stack overflow!`);
10 return;
11 }
12 this.data[this.size] = value;
13 this.size++;
14 }
15
16 // Remove a value from the top of the stack, and return it
17 this.pop = function() {
18 if (this.size === 0) {
19 console.log(`Stack underflow!`);
20 return;
21 }
22 let lastKey = this.size - 1;
23 let result = this.data[lastKey];
24 delete this.data[lastKey];
25 this.size--;
26 return result;
27 }
28}

Now we've got a more resilient structure that will prevent invalid pushes and pops:

1let nsync = new Stack(5);
2nsync.pop(); // Stack underflow!
3nsync.push(`Justin Timberlake`);
4nsync.push(`Lance Bass`);
5nsync.push(`Joey Fatone`);
6nsync.push(`JC Chasez`);
7nsync.push(`Chris Kirkpatrick`);
8nsync.push(`Michael Bublé`); // Stack overflow!

We don't like that dirty pop.

# Why Would We Want to Use a Stack?

1. Performance? (Probably not)

In some languages, a stack has the advantage of being more performant than alternative data structures like arrays. However, JavaScript arrays are optimized so that you're not likely to be able to beat them at efficiency. Array.prototype.push() and Array.prototype.pop() are already O(1) efficient. So no matter the size of the array, it won't take any longer to push items onto or pop them off of the array.

However, this isn't true about other array methods. When we're not only appending to and removing from one end of an array, we lose the stack-like O(1) efficiency. For example, .shift()ing an item to the front of an array -- analogous to the bottom of the stack here -- is only O(n) efficient, because every single item in the array must have its index incremented. With a new array[0], the item previously at array[0] becomes array[1], the item at array[1] becomes array[2], etc. (Technically, this isn't strictly speaking true in JavaScript due to clever optimizations, but it's how it works conceptually, and the optimizations don't change the O(n) efficiency.)

2. Enforcing LIFO

Okay, so arrays' .push() and .pop() methods are pretty efficient in JavaScript. But that doesn't mean stacks are useless. They could be the right choice in situations where you only care about the value most recently added to a list, and you want to enforce that only that value can be accessed.

Say you're building an undo functionality into your drawing web app. Every time a user makes a change to their artwork, you need to push the previous state of the artwork onto a list. Every time a user undoes an action, you need to pop that previous state off the list, so that it becomes the active state of the artwork again.

In this case, it's likely we don't care about accessing artwork states other than the most recently added one. We don't care about needing to access the initial state of the artwork, a blank canvas (this would be the bottom of the stack). And the user isn't ever going to ask us to jump directly to the state it was exactly thirty-seven actions back (so we don't need to access by index, i.e. undoStates[37]). Only the last action ever matters.

A stack could be the right choice for this use case because it enforces the Last In, First Out (LIFO) access order, preventing less efficient O(n) array methods.