Creating Custom Data Structures in JavaScript

Creating Custom Data Structures in JavaScript

For a long time I leaned entirely on arrays and objects for everything, and honestly, they get you pretty far. But once I started working on things like undo/redo systems, graph traversal, and priority-based task queues, I realized JavaScript’s built-ins don’t cover every case efficiently. This article is a walkthrough of the custom data structures I’ve built in JavaScript, why I needed them, and how they actually work under the hood.

Why Built-In Structures Aren’t Always Enough

Arrays in JavaScript are dynamic and flexible, but operations like unshift() (inserting at the front) are O(n) because every existing element has to shift. Objects give you O(1) key lookup but no ordering guarantees for numeric-like manipulation, and no built-in concept of “next” or “previous.” When I need predictable performance characteristics for specific operations, I build a purpose-made structure instead.

Stack

A stack is LIFO (last in, first out) — think of a stack of plates. I use it constantly for undo history and for iterative versions of recursive algorithms.

class Stack {
  #items = [];

  push(item) {
    this.#items.push(item);
  }

  pop() {
    return this.#items.pop();
  }

  peek() {
    return this.#items[this.#items.length - 1];
  }

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

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

const undoStack = new Stack();
undoStack.push('type A');
undoStack.push('type B');
undoStack.push('delete B');

console.log(undoStack.pop()); // "delete B"
console.log(undoStack.peek()); // "type B"

I use the #items private field syntax so consumers of the class can’t reach in and mutate the internal array directly — they have to go through the defined methods.

Queue

FIFO (first in, first out) — used for task scheduling, breadth-first search, and event processing pipelines.

class Queue {
  #items = {};
  #head = 0;
  #tail = 0;

  enqueue(item) {
    this.#items[this.#tail] = item;
    this.#tail++;
  }

  dequeue() {
    if (this.#head === this.#tail) return undefined;
    const item = this.#items[this.#head];
    delete this.#items[this.#head];
    this.#head++;
    return item;
  }

  get size() {
    return this.#tail - this.#head;
  }
}

const printQueue = new Queue();
printQueue.enqueue('doc1.pdf');
printQueue.enqueue('doc2.pdf');

console.log(printQueue.dequeue()); // "doc1.pdf"

I deliberately used a plain object instead of Array.shift() here — shift() is O(n) because it re-indexes every remaining element, while deleting an object key and moving a pointer is O(1).

Linked List

A singly linked list is a chain of nodes, each pointing to the next. I reach for this when I need fast insertion/removal at arbitrary points without shifting a whole array.

class ListNode {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}

class LinkedList {
  #head = null;
  #tail = null;
  #length = 0;

  append(value) {
    const node = new ListNode(value);
    if (!this.#head) {
      this.#head = node;
      this.#tail = node;
    } else {
      this.#tail.next = node;
      this.#tail = node;
    }
    this.#length++;
    return this;
  }

  toArray() {
    const result = [];
    let current = this.#head;
    while (current) {
      result.push(current.value);
      current = current.next;
    }
    return result;
  }

  get length() {
    return this.#length;
  }
}

const list = new LinkedList();
list.append(1).append(2).append(3);
console.log(list.toArray()); // [1, 2, 3]

Binary Search Tree

For maintaining sorted data with fast lookup/insert, I use a binary search tree — each node’s left subtree holds smaller values, right holds larger.

class TreeNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
  }
}

class BinarySearchTree {
  #root = null;

  insert(value) {
    const node = new TreeNode(value);
    if (!this.#root) {
      this.#root = node;
      return this;
    }
    let current = this.#root;
    while (true) {
      if (value < current.value) {
        if (!current.left) { current.left = node; return this; }
        current = current.left;
      } else {
        if (!current.right) { current.right = node; return this; }
        current = current.right;
      }
    }
  }

  contains(value) {
    let current = this.#root;
    while (current) {
      if (value === current.value) return true;
      current = value < current.value ? current.left : current.right;
    }
    return false;
  }
}

const bst = new BinarySearchTree();
[8, 3, 10, 1, 6].forEach((n) => bst.insert(n));

console.log(bst.contains(6)); // true
console.log(bst.contains(99)); // false

Average-case lookup here is O(log n), though a poorly balanced tree (e.g., inserting already-sorted data) degrades to O(n) — which is why self-balancing variants like AVL or Red-Black trees exist for production-grade use.

Priority Queue (via Binary Heap)

I use this for task scheduling by priority, or algorithms like Dijkstra’s shortest path.

class MinPriorityQueue {
  #heap = [];

  #swap(i, j) {
    [this.#heap[i], this.#heap[j]] = [this.#heap[j], this.#heap[i]];
  }

  enqueue(value, priority) {
    this.#heap.push({ value, priority });
    let i = this.#heap.length - 1;
    while (i > 0) {
      const parent = Math.floor((i - 1) / 2);
      if (this.#heap[parent].priority <= this.#heap[i].priority) break;
      this.#swap(i, parent);
      i = parent;
    }
  }

  dequeue() {
    const min = this.#heap[0];
    const last = this.#heap.pop();
    if (this.#heap.length > 0) {
      this.#heap[0] = last;
      let i = 0;
      while (true) {
        const left = 2 * i + 1;
        const right = 2 * i + 2;
        let smallest = i;
        if (left < this.#heap.length && this.#heap[left].priority < this.#heap[smallest].priority) smallest = left;
        if (right < this.#heap.length && this.#heap[right].priority < this.#heap[smallest].priority) smallest = right;
        if (smallest === i) break;
        this.#swap(i, smallest);
        i = smallest;
      }
    }
    return min?.value;
  }
}

const tasks = new MinPriorityQueue();
tasks.enqueue('Low priority task', 5);
tasks.enqueue('Critical bug fix', 1);
tasks.enqueue('Medium task', 3);

console.log(tasks.dequeue()); // "Critical bug fix"
console.log(tasks.dequeue()); // "Medium task"

Using JavaScript’s Native Map and Set Effectively

Before building anything custom, I check whether Map and Set already solve the problem — they’re genuinely underused. Unlike plain objects, Map preserves insertion order for all key types (including objects) and has O(1) average-case get/set without prototype pollution risk.

const cache = new Map();
cache.set('user:1', { name: 'Sara' });
console.log(cache.get('user:1')); // { name: 'Sara' }
console.log(cache.has('user:2')); // false

const uniqueTags = new Set(['js', 'css', 'js', 'html']);
console.log([...uniqueTags]); // ['js', 'css', 'html']

Making Custom Structures Iterable

I like my custom structures to work with for...of and spread syntax just like native ones. That means implementing the iterator protocol via Symbol.iterator:

class LinkedList {
  // ...previous methods

  [Symbol.iterator]() {
    let current = this.#head;
    return {
      next() {
        if (!current) return { value: undefined, done: true };
        const value = current.value;
        current = current.next;
        return { value, done: false };
      },
    };
  }
}

const list = new LinkedList();
list.append('a').append('b').append('c');
for (const item of list) {
  console.log(item); // a, b, c
}
console.log([...list]); // ['a', 'b', 'c']

Performance Comparison

StructureInsertDeleteSearchBest Use Case
ArrayO(n) at front, O(1) at endO(n)O(n)General ordered storage
StackO(1)O(1)O(n)Undo history, DFS, call stacks
Queue (object-backed)O(1)O(1)O(n)Task scheduling, BFS
Linked ListO(1) at known nodeO(1) at known nodeO(n)Frequent insert/remove mid-list
Binary Search TreeO(log n) avgO(log n) avgO(log n) avgSorted, dynamic datasets
Min-Heap / Priority QueueO(log n)O(log n)O(n)Scheduling by priority
Map / SetO(1) avgO(1) avgO(1) avgKey-based lookups, uniqueness

Memory Considerations

Custom structures built from objects (like nodes in a linked list) create more individual heap allocations than a single contiguous array, which can mean more work for the garbage collector under heavy churn. For very large, performance-critical datasets, I benchmark against a plain array or TypedArray first — sometimes the “less elegant” built-in option wins simply because V8 optimizes contiguous memory access so well.

Best Practices

  • Prefer built-ins (Array, Map, Set) unless you have a measured reason not to.
  • Use private class fields (#field) to properly encapsulate internal state.
  • Implement Symbol.iterator so your structures play nicely with the rest of the language.
  • Write structures with clear Big-O guarantees documented, so future-you (or teammates) know when to reach for them.

Common Mistakes

  • Building a custom structure prematurely, before confirming the built-in one is actually a bottleneck.
  • Exposing internal arrays/objects directly, allowing external code to corrupt the structure’s invariants.
  • Forgetting edge cases (empty structure, single element) when implementing methods like dequeue or pop.

FAQs

When should I build a custom data structure instead of using an array? When you have a specific, repeated performance need — like O(1) insertion at both ends, or maintaining sort order — that arrays don’t handle efficiently.

Are linked lists still relevant in JavaScript given how fast V8 arrays are? Mostly in specific scenarios: implementing other structures (queues, LRU caches), or when frequent mid-list insertion/removal is a real bottleneck.

Is Map always better than a plain object? Not always — object literals are still fine and faster to write for simple, static key sets. Map shines with dynamic keys, non-string keys, or when insertion order and size (.size) matter.

Summary and Key Takeaways

Building custom data structures in JavaScript taught me to think in terms of Big-O guarantees rather than just “does it work.” Arrays and objects cover most day-to-day needs, but stacks, queues, linked lists, trees, and heaps each solve a specific access-pattern problem more efficiently. The real skill isn’t memorizing the implementations — it’s recognizing when a specific structure’s guarantees actually matter for the problem in front of you.

References

Total
1
Shares

Leave a Reply

Previous Post
Implementing Authentication in JavaScript Applications

Implementing Authentication in JavaScript Applications: A Practical, Complete Guide

Next Post
Implementing Geolocation Services with JavaScript

Implementing Geolocation Services with JavaScript

Related Posts