DEV Community

Yuta Kusuno
Yuta Kusuno

Posted on • Edited on

[TS] Consider Type-Safe localStorage

This post was featured in Codeminer42 Dev Weekly #10 on Codeminer42 's engineering blog.

In a recent project, I had the chance to explore the use of local storage on the frontend, specifically focusing on type-safety. I would like to share my experiences and insights from this endeavor.

Data type

Initially, I was faced with a variety of data objects that needed to be handled. You might be curious as to why I chose to work with such objects in local storage. The reasons are specific to the project, so I will leave them out of our discussion for now.

src/types.ts

export type Item = {
  id: number;
  name: string;
};
Enter fullscreen mode Exit fullscreen mode

There were a number of different types of objects similar to this one that presented challenges. To tackle this, I adopted an object-oriented programming approach. This strategy helped reduce development time and facilitated the early detection of unexpected errors through component reuse.

Custom Storage

Custom storage is a utility that offers generic local storage operations. These operations can be utilized by other specific local storage classes, which I will elaborate on later.

src/lib/custom-storage.ts

export class CustomStorage<T> {
  private key: string;

  constructor(key: string) {
    this.key = key;
  }

  public getItems(): T | null {
    try {
      const data = localStorage.getItem(this.key);
      if (!data) return null;

      return JSON.parse(data) as T;
    } catch (error) {
      throw new Error('Failed to retrieve items from storage');
    }
  }

  public setItems(items: T[]): void {
    try {
      localStorage.setItem(this.key, JSON.stringify(items));
    } catch (error) {
      throw new Error('Failed to set items in storage');
    }
  }

  public clearItems(): void {
    try {
      localStorage.removeItem(this.key);
    } catch (error) {
      throw new Error('Failed to clear items from storage');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The custom storage has three public methods: getItems, setItems, and clearItems. These methods operate the local storage API in a basic way, with any errors being handled as exceptions.

Items Storage

The ItemsStorage instance and its public methods are invoked in event handlers, such as those triggered by onClick. Initially, I thought of having this class inherit from the CustomStorage class. However, I realized that local storage itself is indifferent to data inconsistency. So, I implemented a polymorphism-like approach to check for data inconsistencies.

In the actual project, I have created similar classes like ItemsStorageA, ItemsStorageB, and ItemsStorageC.

src/lib/items-storage.ts

import { Item } from '../types';
import { CustomStorage } from './custom-storage';
import { isValidItems, isValidItemFields } from '../utils';

const STORAGE_ITEMS_KEY = 'items';

class ItemsStorage {
  private static instance: ItemsStorage;
  private storage: CustomStorage<Item>;

  private constructor() {
    this.storage = new CustomStorage<Item>(STORAGE_ITEMS_KEY);
  }

  public static getInstance(): ItemsStorage {
    return this.instance ?? (this.instance = new ItemsStorage());
  }

  public getItems(): Item[] | null {
    try {
      const items = this.storage.getItems();
      if (!isValidItems(items)) {
        throw new Error('Invalid items');
      }

      return items;
    } catch (error: unknown) {
      this.logError(error);
      return null;
    }
  }

  public setItem(item: Item): void {
    try {
      if (!isValidItemFields(item)) {
        throw new Error('Invalid item fields');
      }

      const currentItems = this.storage.getItems();
      if (!isValidItems(currentItems)) {
        throw new Error('Invalid items');
      }

      const items: Item[] = [...currentItems, item];
      this.storage.setItems(items);
    } catch (error: unknown) {
      this.logError(error);
    }
  }

  public clearItems(): void {
    try {
      this.storage.clearItems();
    } catch (error: unknown) {
      this.logError(error);
    }
  }

  private logError(error: unknown): void {
    let errorMessage = 'An error occurred in ItemsStorage';
    if (error instanceof Error) {
      errorMessage += `: ${error.message}`;
    }
    console.error(errorMessage);
  }
}

export const itemsStorage = ItemsStorage.getInstance();
Enter fullscreen mode Exit fullscreen mode

I designed ItemsStorage as a singleton, but it doesnโ€™t necessarily have to be one. This was more a matter of convenience I wanted to avoid creating a new instance every time it was called. Since this class merely operates the local storage, we could instantiate it at the bottom of the file and export it.

When the getItems or setItem methods are invoked, they check for data consistency. If an inconsistency is detected in the existing data or in the data to be added, an error is thrown, and the data retrieval or addition operation is halted. One approach to handling invalid data during the addition process is to replace all the data with the new data, without stopping the process if even a single invalid data point is found. However, this method could potentially delay the discovery of bugs and compromise debugging efficiency.

The below utils.ts file contains several validation functions for the ItemsStorage class methods. These functions perform type checking and null checking. Originally, these were part of the ItemsStorage class, but I moved them to utils.ts. If there is a future need for active use of local storage more, I would like to further abstract these functions for reuse.

src/utils.ts

import { Item } from './types';

export function isValidItems(items: unknown): items is Item[] {
  if (!items || !Array.isArray(items)) {
    return false;
  }

  for (const item of items) {
    if (!isValidItemFields(item)) {
      return false;
    }
  }

  return true;
}

export function isValidItemFields(item: unknown): item is Item {
  if (!item || typeof item !== 'object') {
    return false;
  }

  if (!('id' in item) || !('name' in item)) {
    return false;
  }

  return idNumber(item.id) && isString(item.name);
}

export function idNumber(num: unknown): num is number {
  return typeof num === 'number';
}

export function isString(str: unknown): str is string {
  return typeof str === 'string';
}
Enter fullscreen mode Exit fullscreen mode

Demo Page

Finally, I have prepared a demo page. This page includes three operations: retrieving items, adding an item, and clearing items, all of which are handled by event handlers.

src/App.tsx

import { itemsStorage } from './lib/items-storage';
import './App.css';

function App() {
  const handleAddItem = () => {
    const item = { id: Math.random(), name: 'Item' };
    itemsStorage.setItem(item);
  };

  const handleGetItems = () => {
    const items = itemsStorage.getItems();
    console.log('Items:', items);
  };

  const handleClearItems = () => {
    itemsStorage.clearItems();
  };

  return (
    <>
      <h1>localStorage Demo</h1>
      <div className='card'>
        <button onClick={handleAddItem}>Add Item</button>
        <button onClick={handleGetItems}>Get Items</button>
        <button onClick={handleClearItems}>Clear Items</button>
      </div>
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Thank you for reading this article to the end. While it is unlikely we will often encounter the need to actively use local storage like this, if such a time comes again, I am confident that the lessons learned here will prove invaluable!!

I have summarized this demo in a repository. If you are interested, feel free to check it out!
https://github.com/yutakusuno/type-safe-local-storage

That is about it. Happy coding!

Top comments (1)

Collapse
 
ricardogesteves profile image
Ricardo Esteves

informative, thanks for sharing it.