[TS] Consider Type-Safe localStorage
Tags: typescript, react, node, javascript
Published: 2024-05-05 04:57:55

> This post was featured in [Codeminer42 Dev Weekly #10](https://blog.codeminer42.com/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

```typescript
export type Item = {
  id: number;
  name: string;
};
```

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

```typescript
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');
    }
  }
}
```

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

```typescript
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();
```

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

```typescript
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';
}
```

## 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

```typescript
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;
```

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!