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
idproperty - 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:
Omit<T, "id">- Removeidfrom the type (cannot change id)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
- Constraints are essential:
T extends Identifiableensures all items have IDs - Utility types add flexibility:
Partial<Omit<T, "id">>creates safe update types - Generic methods preserve types:
filterBy<K extends keyof T>maintains type safety - Events enable reactivity: Subscribe pattern allows responding to changes
- Bulk operations improve efficiency:
addMany,updateMany,deleteMany - 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.