From Zero to AI

Lesson 5.5: Practice - Generic Storage

Duration: 80 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Build a complete generic storage system from scratch
  • Apply generics, constraints, and utility types together
  • Implement CRUD operations with type safety
  • Create flexible filtering and querying capabilities
  • Design reusable, production-ready code

Project Overview

We will build a Storage class that:

  • Stores any type of data with full type safety
  • Requires items to have an id property
  • Supports Create, Read, Update, and Delete operations
  • Provides filtering, searching, and sorting
  • Uses utility types for flexibility

By the end, you will have a reusable storage system that works with users, products, posts, or any data type.


Step 1: Define the Base Interface

Every item in our storage needs an identifier. Let us define a base interface:

interface Identifiable {
  id: number | string;
}

This constraint ensures all stored items have an id property that can be a number or string.


Step 2: Create the Storage Class

Let us start with a basic storage class:

class Storage<T extends Identifiable> {
  private items: Map<T['id'], T> = new Map();

  // Create
  add(item: T): T {
    if (this.items.has(item.id)) {
      throw new Error(`Item with id ${item.id} already exists`);
    }
    this.items.set(item.id, item);
    return item;
  }

  // Read
  get(id: T['id']): T | undefined {
    return this.items.get(id);
  }

  // Get all items
  getAll(): T[] {
    return Array.from(this.items.values());
  }

  // Check existence
  has(id: T['id']): boolean {
    return this.items.has(id);
  }

  // Count
  count(): number {
    return this.items.size;
  }
}

Usage So Far

interface User extends Identifiable {
  id: number;
  name: string;
  email: string;
}

const userStorage = new Storage<User>();

userStorage.add({ id: 1, name: 'Alice', email: 'alice@example.com' });
userStorage.add({ id: 2, name: 'Bob', email: 'bob@example.com' });

console.log(userStorage.get(1)); // { id: 1, name: "Alice", ... }
console.log(userStorage.count()); // 2

Step 3: Add Update and Delete

Now let us add the remaining CRUD operations:

class Storage<T extends Identifiable> {
  private items: Map<T['id'], T> = new Map();

  // ... previous methods ...

  // Update - replace entire item
  update(id: T['id'], item: T): T | undefined {
    if (!this.items.has(id)) {
      return undefined;
    }

    // Ensure the id matches
    if (item.id !== id) {
      throw new Error('Item id does not match the provided id');
    }

    this.items.set(id, item);
    return item;
  }

  // Partial update - only update specified fields
  patch(id: T['id'], updates: Partial<Omit<T, 'id'>>): T | undefined {
    const existing = this.items.get(id);
    if (!existing) {
      return undefined;
    }

    const updated = { ...existing, ...updates };
    this.items.set(id, updated);
    return updated;
  }

  // Delete
  delete(id: T['id']): boolean {
    return this.items.delete(id);
  }

  // Clear all
  clear(): void {
    this.items.clear();
  }
}

Why Partial<Omit<T, "id">>?

The patch method uses Partial<Omit<T, "id">> to:

  1. Omit<T, "id"> - Remove id from the type (cannot change id)
  2. Partial<...> - Make all remaining fields optional
// User patch type becomes:
// {
//   name?: string;
//   email?: string;
// }

userStorage.patch(1, { name: 'Alicia' }); // OK - only update name
userStorage.patch(1, { id: 99 }); // Error - cannot update id

Step 4: Add Filtering

Let us add powerful filtering capabilities:

class Storage<T extends Identifiable> {
  // ... previous methods ...

  // Find items matching a predicate
  find(predicate: (item: T) => boolean): T[] {
    return this.getAll().filter(predicate);
  }

  // Find first item matching a predicate
  findOne(predicate: (item: T) => boolean): T | undefined {
    return this.getAll().find(predicate);
  }

  // Filter by property value
  filterBy<K extends keyof T>(key: K, value: T[K]): T[] {
    return this.getAll().filter((item) => item[key] === value);
  }

  // Filter by multiple criteria
  filterByMany(criteria: Partial<T>): T[] {
    return this.getAll().filter((item) => {
      return Object.entries(criteria).every(([key, value]) => {
        return item[key as keyof T] === value;
      });
    });
  }
}

Filter Usage

interface Product extends Identifiable {
  id: number;
  name: string;
  category: string;
  price: number;
  inStock: boolean;
}

const productStorage = new Storage<Product>();

productStorage.add({ id: 1, name: 'Laptop', category: 'Electronics', price: 999, inStock: true });
productStorage.add({ id: 2, name: 'Phone', category: 'Electronics', price: 599, inStock: true });
productStorage.add({ id: 3, name: 'Desk', category: 'Furniture', price: 299, inStock: false });

// Find by predicate
const expensive = productStorage.find((p) => p.price > 500);
// [Laptop, Phone]

// Filter by property
const electronics = productStorage.filterBy('category', 'Electronics');
// [Laptop, Phone]

// Filter by multiple criteria
const availableElectronics = productStorage.filterByMany({
  category: 'Electronics',
  inStock: true,
});
// [Laptop, Phone]

Step 5: Add Sorting

Add sorting capabilities to the storage:

class Storage<T extends Identifiable> {
  // ... previous methods ...

  // Sort by a property
  sortBy<K extends keyof T>(key: K, order: 'asc' | 'desc' = 'asc'): T[] {
    return this.getAll().sort((a, b) => {
      const aVal = a[key];
      const bVal = b[key];

      let comparison = 0;
      if (aVal < bVal) comparison = -1;
      if (aVal > bVal) comparison = 1;

      return order === 'desc' ? -comparison : comparison;
    });
  }

  // Sort with custom comparator
  sortWith(comparator: (a: T, b: T) => number): T[] {
    return this.getAll().sort(comparator);
  }
}

Sort Usage

// Sort by price ascending
const cheapFirst = productStorage.sortBy('price', 'asc');

// Sort by name descending
const zToA = productStorage.sortBy('name', 'desc');

// Custom sort
const byStockThenPrice = productStorage.sortWith((a, b) => {
  // In stock items first
  if (a.inStock !== b.inStock) {
    return a.inStock ? -1 : 1;
  }
  // Then by price
  return a.price - b.price;
});

Step 6: Add Bulk Operations

For efficiency, let us add bulk operations:

class Storage<T extends Identifiable> {
  // ... previous methods ...

  // Add multiple items
  addMany(items: T[]): T[] {
    const added: T[] = [];
    for (const item of items) {
      try {
        this.add(item);
        added.push(item);
      } catch {
        // Skip duplicates
      }
    }
    return added;
  }

  // Update multiple items
  updateMany(updates: Array<{ id: T['id']; data: Partial<Omit<T, 'id'>> }>): T[] {
    const updated: T[] = [];
    for (const { id, data } of updates) {
      const result = this.patch(id, data);
      if (result) {
        updated.push(result);
      }
    }
    return updated;
  }

  // Delete multiple items
  deleteMany(ids: T['id'][]): number {
    let deleted = 0;
    for (const id of ids) {
      if (this.delete(id)) {
        deleted++;
      }
    }
    return deleted;
  }

  // Delete by condition
  deleteWhere(predicate: (item: T) => boolean): number {
    const toDelete = this.find(predicate);
    return this.deleteMany(toDelete.map((item) => item.id));
  }
}

Step 7: Add Events (Optional Enhancement)

For reactive applications, add event support:

type StorageEvent<T> =
  | { type: 'add'; item: T }
  | { type: 'update'; item: T; previous: T }
  | { type: 'delete'; item: T }
  | { type: 'clear' };

type EventListener<T> = (event: StorageEvent<T>) => void;

class Storage<T extends Identifiable> {
  private items: Map<T['id'], T> = new Map();
  private listeners: EventListener<T>[] = [];

  // Subscribe to events
  subscribe(listener: EventListener<T>): () => void {
    this.listeners.push(listener);
    return () => {
      const index = this.listeners.indexOf(listener);
      if (index > -1) {
        this.listeners.splice(index, 1);
      }
    };
  }

  private emit(event: StorageEvent<T>): void {
    this.listeners.forEach((listener) => listener(event));
  }

  // Modified add method
  add(item: T): T {
    if (this.items.has(item.id)) {
      throw new Error(`Item with id ${item.id} already exists`);
    }
    this.items.set(item.id, item);
    this.emit({ type: 'add', item });
    return item;
  }

  // Modified patch method
  patch(id: T['id'], updates: Partial<Omit<T, 'id'>>): T | undefined {
    const existing = this.items.get(id);
    if (!existing) {
      return undefined;
    }

    const updated = { ...existing, ...updates };
    this.items.set(id, updated);
    this.emit({ type: 'update', item: updated, previous: existing });
    return updated;
  }

  // Modified delete method
  delete(id: T['id']): boolean {
    const item = this.items.get(id);
    if (!item) {
      return false;
    }
    this.items.delete(id);
    this.emit({ type: 'delete', item });
    return true;
  }

  // Modified clear method
  clear(): void {
    this.items.clear();
    this.emit({ type: 'clear' });
  }
}

Event Usage

const storage = new Storage<User>();

const unsubscribe = storage.subscribe((event) => {
  switch (event.type) {
    case 'add':
      console.log(`Added: ${event.item.name}`);
      break;
    case 'update':
      console.log(`Updated: ${event.item.name} (was: ${event.previous.name})`);
      break;
    case 'delete':
      console.log(`Deleted: ${event.item.name}`);
      break;
    case 'clear':
      console.log('Storage cleared');
      break;
  }
});

storage.add({ id: 1, name: 'Alice', email: 'alice@example.com' });
// Logs: "Added: Alice"

storage.patch(1, { name: 'Alicia' });
// Logs: "Updated: Alicia (was: Alice)"

unsubscribe(); // Stop listening

Complete Implementation

Here is the complete Storage class with all features:

interface Identifiable {
  id: number | string;
}

type StorageEvent<T> =
  | { type: 'add'; item: T }
  | { type: 'update'; item: T; previous: T }
  | { type: 'delete'; item: T }
  | { type: 'clear' };

type EventListener<T> = (event: StorageEvent<T>) => void;

class Storage<T extends Identifiable> {
  private items: Map<T['id'], T> = new Map();
  private listeners: EventListener<T>[] = [];

  // Event handling
  subscribe(listener: EventListener<T>): () => void {
    this.listeners.push(listener);
    return () => {
      const index = this.listeners.indexOf(listener);
      if (index > -1) {
        this.listeners.splice(index, 1);
      }
    };
  }

  private emit(event: StorageEvent<T>): void {
    this.listeners.forEach((listener) => listener(event));
  }

  // Create
  add(item: T): T {
    if (this.items.has(item.id)) {
      throw new Error(`Item with id ${item.id} already exists`);
    }
    this.items.set(item.id, item);
    this.emit({ type: 'add', item });
    return item;
  }

  addMany(items: T[]): T[] {
    const added: T[] = [];
    for (const item of items) {
      try {
        this.add(item);
        added.push(item);
      } catch {
        // Skip duplicates
      }
    }
    return added;
  }

  // Read
  get(id: T['id']): T | undefined {
    return this.items.get(id);
  }

  getAll(): T[] {
    return Array.from(this.items.values());
  }

  has(id: T['id']): boolean {
    return this.items.has(id);
  }

  count(): number {
    return this.items.size;
  }

  // Update
  update(id: T['id'], item: T): T | undefined {
    if (!this.items.has(id)) {
      return undefined;
    }
    if (item.id !== id) {
      throw new Error('Item id does not match the provided id');
    }
    const previous = this.items.get(id)!;
    this.items.set(id, item);
    this.emit({ type: 'update', item, previous });
    return item;
  }

  patch(id: T['id'], updates: Partial<Omit<T, 'id'>>): T | undefined {
    const existing = this.items.get(id);
    if (!existing) {
      return undefined;
    }
    const updated = { ...existing, ...updates };
    this.items.set(id, updated);
    this.emit({ type: 'update', item: updated, previous: existing });
    return updated;
  }

  updateMany(updates: Array<{ id: T['id']; data: Partial<Omit<T, 'id'>> }>): T[] {
    const results: T[] = [];
    for (const { id, data } of updates) {
      const result = this.patch(id, data);
      if (result) {
        results.push(result);
      }
    }
    return results;
  }

  // Delete
  delete(id: T['id']): boolean {
    const item = this.items.get(id);
    if (!item) {
      return false;
    }
    this.items.delete(id);
    this.emit({ type: 'delete', item });
    return true;
  }

  deleteMany(ids: T['id'][]): number {
    let deleted = 0;
    for (const id of ids) {
      if (this.delete(id)) {
        deleted++;
      }
    }
    return deleted;
  }

  deleteWhere(predicate: (item: T) => boolean): number {
    const toDelete = this.find(predicate);
    return this.deleteMany(toDelete.map((item) => item.id));
  }

  clear(): void {
    this.items.clear();
    this.emit({ type: 'clear' });
  }

  // Query
  find(predicate: (item: T) => boolean): T[] {
    return this.getAll().filter(predicate);
  }

  findOne(predicate: (item: T) => boolean): T | undefined {
    return this.getAll().find(predicate);
  }

  filterBy<K extends keyof T>(key: K, value: T[K]): T[] {
    return this.getAll().filter((item) => item[key] === value);
  }

  filterByMany(criteria: Partial<T>): T[] {
    return this.getAll().filter((item) => {
      return Object.entries(criteria).every(([key, value]) => {
        return item[key as keyof T] === value;
      });
    });
  }

  // Sort
  sortBy<K extends keyof T>(key: K, order: 'asc' | 'desc' = 'asc'): T[] {
    return this.getAll().sort((a, b) => {
      const aVal = a[key];
      const bVal = b[key];
      let comparison = 0;
      if (aVal < bVal) comparison = -1;
      if (aVal > bVal) comparison = 1;
      return order === 'desc' ? -comparison : comparison;
    });
  }

  sortWith(comparator: (a: T, b: T) => number): T[] {
    return this.getAll().sort(comparator);
  }
}

Usage Examples

User Storage

interface User extends Identifiable {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  active: boolean;
}

const users = new Storage<User>();

// Add users
users.addMany([
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin', active: true },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user', active: true },
  { id: 3, name: 'Charlie', email: 'charlie@example.com', role: 'user', active: false },
  { id: 4, name: 'Diana', email: 'diana@example.com', role: 'guest', active: true },
]);

// Query
const admins = users.filterBy('role', 'admin');
const activeUsers = users.filterByMany({ role: 'user', active: true });
const byName = users.sortBy('name');

// Update
users.patch(2, { role: 'admin' });

// Delete inactive users
users.deleteWhere((u) => !u.active);

console.log(users.count()); // 3

Product Inventory

interface Product extends Identifiable {
  id: string;
  sku: string;
  name: string;
  price: number;
  quantity: number;
  category: string;
}

const inventory = new Storage<Product>();

// Subscribe to changes
inventory.subscribe((event) => {
  if (event.type === 'update') {
    const product = event.item;
    if (product.quantity === 0) {
      console.log(`ALERT: ${product.name} is out of stock!`);
    } else if (product.quantity < 10) {
      console.log(`WARNING: ${product.name} is running low (${product.quantity} left)`);
    }
  }
});

// Add products
inventory.add({
  id: 'prod-001',
  sku: 'LAPTOP-15',
  name: 'Laptop 15 inch',
  price: 999,
  quantity: 50,
  category: 'Electronics',
});

// Simulate sale
inventory.patch('prod-001', { quantity: 5 });
// Logs: WARNING: Laptop 15 inch is running low (5 left)

Exercises

Exercise 1: Add Search Functionality

Add a search method that searches text fields:

// Add this method to Storage
search(query: string, keys: (keyof T)[]): T[];

// Usage
const results = users.search("alice", ["name", "email"]);
Solution
search(query: string, keys: (keyof T)[]): T[] {
  const lowerQuery = query.toLowerCase();

  return this.getAll().filter(item => {
    return keys.some(key => {
      const value = item[key];
      if (typeof value === "string") {
        return value.toLowerCase().includes(lowerQuery);
      }
      return String(value).toLowerCase().includes(lowerQuery);
    });
  });
}

// Usage
const results = users.search("alice", ["name", "email"]);

Exercise 2: Add Pagination

Add pagination support:

interface PaginatedResult<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

// Add this method
paginate(page: number, pageSize: number): PaginatedResult<T>;
Solution
interface PaginatedResult<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

paginate(page: number, pageSize: number): PaginatedResult<T> {
  const all = this.getAll();
  const total = all.length;
  const totalPages = Math.ceil(total / pageSize);
  const start = (page - 1) * pageSize;
  const items = all.slice(start, start + pageSize);

  return {
    items,
    total,
    page,
    pageSize,
    totalPages
  };
}

// Usage
const page1 = users.paginate(1, 10);
// { items: [...], total: 100, page: 1, pageSize: 10, totalPages: 10 }

Exercise 3: Add Upsert

Add an upsert method that updates if exists, creates if not:

// Add this method
upsert(item: T): { item: T; created: boolean };
Solution
upsert(item: T): { item: T; created: boolean } {
  if (this.has(item.id)) {
    this.update(item.id, item);
    return { item, created: false };
  } else {
    this.add(item);
    return { item, created: true };
  }
}

// Usage
const result1 = users.upsert({ id: 1, name: "Alice Updated", ... });
// { item: {...}, created: false }

const result2 = users.upsert({ id: 999, name: "New User", ... });
// { item: {...}, created: true }

Key Takeaways

  1. Constraints are essential: T extends Identifiable ensures all items have IDs
  2. Utility types add flexibility: Partial<Omit<T, "id">> creates safe update types
  3. Generic methods preserve types: filterBy<K extends keyof T> maintains type safety
  4. Events enable reactivity: Subscribe pattern allows responding to changes
  5. Bulk operations improve efficiency: addMany, updateMany, deleteMany
  6. Design for reusability: This storage works with any data type

Resources

Resource Type Description
TypeScript Handbook: Generics Documentation Complete generics guide
TypeScript Handbook: Utility Types Documentation All utility types
TypeScript Playground Tool Test your code online

Module Complete!

Congratulations! You have completed Module 5: Generics and Utilities. You now understand:

  • Why generics exist and how to use them
  • Generic functions with constraints and defaults
  • Generic classes and interfaces for reusable code
  • Built-in utility types for type transformations
  • How to combine these concepts in real projects

Continue your learning journey with Module 6, where you will learn to organize code into modules.

Continue to Module 6: Modules and Organization