The Stuttgart library

Image by: Niklas Ohlrogge

Building an Autocomplete System with Trie

Published on: August 9, 2025

Creating a Trie-based autocomplete system in TypeScript.

Table of Contents

Trie Data Structure

A Trie is a tree-like data structure designed to store strings efficiently for fast prefix queries. Each node represents a character in the string, and paths from the root to a node form prefixes.

This section is inspired by and adapted from ByteByteGo’s Design a Search Autocomplete System.

Key points:

In the context of autocomplete, top-k means returning only the k most relevant or highest-ranked suggestions for a given prefix, rather than all possible matches.

Example:

If a Trie contains the words true (35), try (29), tree (10), and a user types tr with k=2, we return: [true: 35, try: 29]

Instead of all matches, we show only the top 2 by score.

Advantages:

Disadvantages:

For example, storing cat and car will share the path ca.

graph LR
  R((root))
  R --> C[c]
  C --> A[a]
  A --> T[t]
  A --> R2[r]
  R2 --> T2[t]
  R2 --> E[e]

  T  --> CAT["cat"]
  R2 --> CAR["car"]
  T2 --> CART["cart"]
  E --> CARE["care"]

  classDef endNode fill:#9f9,stroke:#333,stroke-width:2px;
  class T,R2,T2,E endNode;

  style R fill:#f9c,stroke:#333,stroke-width:2px;

Why Trie for Autocomplete?

We need fast prefix lookups. A Trie (prefix tree) lets us jump directly to the node that represents the typed prefix and then list matching words under it. This is usually faster than scanning an index in a relational database for every keystroke.

Tries vs Trees

graph TD
  subgraph "B-tree Example"
    BRoot["Root: [30]"] 
    BRoot --> BL1["Left: [10, 20]"]
    BRoot --> BR1["Right: [40, 50]"]
    
    BL1 --> BL2["[5, 8]"]
    BL1 --> BL3["[12, 15]"]
    BL1 --> BL4["[22, 25]"]
    
    BR1 --> BR2["[35, 38]"]
    BR1 --> BR3["[42, 45]"]
    BR1 --> BR4["[55, 60]"]
  end

  subgraph "Trie Example"
    TRoot(("root")) --> C["c"]
    C --> A["a"]
    A --> T["t"]
    A --> R["r"]
    R --> T2["t"]
    
    TRoot --> D["d"]
    D --> O["o"]
    O --> G["g"]
  end

  classDef endNode fill:#9f9,stroke:#333,stroke-width:2px;
  class T,T2,G endNode;

  classDef blNode fill:#ffe,stroke:#333,stroke-width:1px;
  class BL2,BL3,BL4 blNode;

  classDef brNode fill:#ffe,stroke:#333,stroke-width:1px;
  class BR2,BR3,BR4 brNode;

  style BRoot fill:#bbf,stroke:#333,stroke-width:2px;
  style TRoot fill:#f9c,stroke:#333,stroke-width:2px;

Are they the same?

No. Both are tree structures, but they work differently:

Tree (like B-tree):

Trie:

Key differences:

In short: Every trie is a tree, but not every tree is a trie. Use tries when you need fast prefix matching for words.

Trie Operations

Common operations on a Trie include:

Why these times?

The key insight: search time depends on word length, not how many total words are stored!

Data Model

For our autocomplete system, we need to store more than just the words. Each suggestion should have additional data like frequency (how often it’s searched).

interface TrieNode {
  children: Map<string, TrieNode>; // character -> next node
  isEndOfWord: boolean; // marks valid word ending
  frequency: number; // how popular this word is
}

interface WordData {
  text: string; // the actual word
  frequency: number; // search count or popularity score
}

Implementation

This section will walk through creating a basic project structure for a TypeScript-based autocomplete system using a Trie.

src/
├── types/
   └── data.ts           # Type definitions and sample data
├── core/
   ├── trie.ts           # Trie data structure implementation
   └── autocomplete.ts   # AutocompleteSystem wrapper

Below is our Trie implementation, with explanations for each part:

TrieNode class: Represents a single node in the trie. It contains:

// src/core/trie.ts
export class TrieNode {
  children: Map<string, TrieNode>;
  isEndOfWord: boolean;
  frequency: number;
  wordData?: WordData;

  constructor() {
    this.children = new Map();
    this.isEndOfWord = false;
    this.frequency = 0;
  }
}

Trie class: The main data structure.

// src/core/trie.ts
export class Trie {
  private root: TrieNode;

  constructor() {
    this.root = new TrieNode();
  }

  insert(word: string, frequency: number = 1): void {
    let currentNode = this.root;

    for (const char of word.toLowerCase()) {
      if (!currentNode.children.has(char)) {
        currentNode.children.set(char, new TrieNode());
      }

      currentNode = currentNode.children.get(char)!;
    }

    currentNode.isEndOfWord = true;
    currentNode.frequency = frequency;
    currentNode.wordData = {
      text: word,
      frequency,
    };
  }

  search(word: string): boolean {
    const node = this.findNode(word.toLowerCase());
    return node?.isEndOfWord ?? false;
  }

  startsWith(prefix: string): boolean {
    return this.findNode(prefix.toLowerCase()) !== null;
  }

  private findNode(word: string): TrieNode | null {
    let currentNode = this.root;

    for (const char of word) {
      if (!currentNode.children.has(char)) {
        return null;
      }
      currentNode = currentNode.children.get(char)!;
    }

    return currentNode;
  }

  suggest(prefix: string, k: number = 10): WordData[] {
    const prefixNode = this.findNode(prefix.toLowerCase());
    if (!prefixNode) {
      return [];
    }

    const suggestions: WordData[] = [];
    this.collectWords(prefixNode, suggestions);

    return suggestions.sort((a, b) => b.frequency - a.frequency).slice(0, k);
  }

  private collectWords(node: TrieNode, results: WordData[]): void {
    if (node.isEndOfWord && node.wordData) {
      results.push(node.wordData);
    }

    for (const childNode of node.children.values()) {
      this.collectWords(childNode, results);
    }
  }

  incrementFrequency(word: string): boolean {
    const node = this.findNode(word.toLowerCase());
    if (node && node.isEndOfWord && node.wordData) {
      node.frequency++;
      node.wordData.frequency++;
      return true;
    }
    return false;
  }

  getFrequency(word: string): number {
    const node = this.findNode(word.toLowerCase());
    if (node && node.isEndOfWord) {
      return node.frequency;
    }
    return 0;
  }

  delete(word: string): boolean {
    return this.deleteHelper(this.root, word.toLowerCase(), 0);
  }

  private deleteHelper(node: TrieNode, word: string, index: number): boolean {
    if (index === word.length) {
      if (!node.isEndOfWord) {
        return false;
      }

      node.isEndOfWord = false;
      node.wordData = undefined;

      return node.children.size === 0;
    }

    const char = word[index];
    if (!char) {
      return false;
    }

    const childNode = node.children.get(char);

    if (!childNode) {
      return false;
    }

    const shouldDeleteChild = this.deleteHelper(childNode, word, index + 1);

    if (shouldDeleteChild) {
      node.children.delete(char);
      return node.children.size === 0 && !node.isEndOfWord;
    }

    return false;
  }
}

You can find the full implementation on GitHub: View the full code on GitHub

Conclusion

We explored how to build an efficient autocomplete system using the Trie data structure. We covered the fundamental concepts, implementation details.

This was built as a learning exercise — a humble starting point for anyone interested in experimenting with Tries and building their own search features.

References

Tags: #trie #autocomplete #typescript #en