Create a Type Safe Local Storage Service

Let's create a simple Angular service to abstract the localStorage and sessionStorage API in a type-safe manner.

featured image

A common way to store data on the client's browser is to use the local storage or session storage API. All major web browsers support those features, making it convenient for developers to use them without hassle. The main difference between those APIs is that session storage data expires after the specific URL's browser tab is closed. Data stored using local storage has no expiration date and is present at the next website visit, except the user forces to delete the data.

When it comes to writing data to the storage, we have a simple key-value interface where both the key and the value are a string type.

localStorage.setItem('email', 'careers@qupaya.com');
localStorage.getItem('email'); // outputs "careers@qupaya.com"

Developers who use vanilla JavaScript without type checkers such as TypeScript or Flow can pass any value such as numbers or objects. JavaScript type coercion will automatically cast the given value argument to a string. When reading the data, you will get a (probably unexpected) string result anyway.

localStorage.setItem('amount', 123);
localStorage.getItem('amount'): // outputs "123"

localStorage.setItem('complex', { hello: 'world' });
localStorage.getItem('complex'); // outputs "[object Object]"

To ensure, whether in TypeScript or JavaScript, to work with the proper type after retrieving the value, we have to cast manually. To work with primitive types is quite simple, but it gets a bit more tricky for complex JSON types since we have to store a stringified object before storage.

localStorage.setItem('amount', 123);
parseInt(localStorage.getItem('amount'), 10); // outputs 123

localStorage.setItem('complex', JSON.stringify({ hello: 'world' }));
JSON.parse(localStorage.getItem('complex')); // outputs { hello: 'world' }

Getting non-existing data

Our examples above are almost type-safe. But, what if we are trying to read data that is not present? According to the documentation, the getItem function will return null if the corresponding key is not existing. For this case, we have to check if the value exists.

const value: string | null = localStorage.getItem('amount');
let amount: number = 0; // We define 0 as default amount

if (value !== null) {
  amount = parseInt(value, 10);
}

We have to assume that the returned data is correct at the current development stage and is castable to a number.

Automatic Type Casting

Well, our code is getting more and more reliable. Now it's time to reduce boilerplate code by using the benefits of JavaScript's JSON.stringify and JSON.parse functions.

JSON (JavaScript Object Notation) is a format to describe simple JS objects, such as plain objects or arrays, which can be nested and contain primitive values. The stringify method converts those objects to a string representation, whereas parse returns a qualified JS object from a given string.

We will abstract setItem and getItem functions to wrap every data within a type-safe JSON object in the next step.

Create a Service And Wrap localStorage

In this JSONish solution, it is mandatory to wrap our data within an object property; we name it value because this is what it is. The setItem method now can take any serializable value to store. However, getItem underlies a generic type since the real stored value is unknown during runtime.

class LocalStorageService {
  public setItem(key: string, value: any): void {
    localStorage.setItem(key, JSON.stringify({ value }));
  }

  public getItem<T>(key: string): T | null {
    const data: string | null = localStorage.getItem(key);

    if (data !== null) {
      return JSON.parse(data).value;
    }

    return null;
  }
}

Please notice that we still have to check if the value exists or not when using the service.

// Write data
service.setItem('amount', 123);

// Read data
const value: number | null = service.getItem<number>('amount');
let amount: number = 0;

if (value !== null) {
  amount = value;
}

As you can see in the snippet below, the browser now stores the data within the JSON structure's correct type.

key(string):   amount
value(string): {"value":123}

Add Support For Default Values and Conditional Types

In most cases, we are going to use default values if no data is present. The current solution does not pretend from checking against null at each read operation. Let's extend and overload the getItem method to define a default return value if no data exist.

class LocalStorageService {
  setItem(key: string, value: any): void {
    localStorage.setItem(key, JSON.stringify({ value }));
  }

  getItem<T>(key: string): T | null;
  getItem<T>(key: string, otherwise: T): T;
  getItem<T>(key: string, otherwise?: T): T | null {
    const data: string | null = localStorage.getItem(key);

    if (data !== null) {
      return JSON.parse(data).value;
    }

    if (otherwise) {
      return otherwise;
    }

    return null;
  }
}

TypeScript's method overloading is a fantastic way to specify the call signature of getItem. If the default argument otherwise is passed, we expect the same type as the return value. Otherwise, the result could be nullable.

// Providing a default value ensures the return type number
const amount: number = service.getItem<number>('amount', 0);

// No default value makes the return value nullable
const amount2: number | null = service.getItem<number>('amount');

// ERROR!
// Type 'number | null' is not assignable to type 'number'.
//   Type 'null' is not assignable to type 'number'.
const amountErr2: number = service.getItem<number>('amount');

As we have type-safety and default values, we can focus on further problems that occur when using local storage from time to time.

Prevent Data Collision

Both local storage and session storage are underlying the Same Origin Policy (SOP), which means that the data relates to the domain (origin). There is a big chance to end up in name collisions at large web applications, widget mashups, or developing several local front-end projects at the same port (e. g. localhost:4200 for Angular). Common names for keys, for example, are email, username, jwt, or accessToken. Using a key-prefix is an excellent solution to avoid naming conflicts.

In this example, we make the storage service configurable to pass a custom prefix used for each operation.

Please click on the frame below to check out the code example.

Add Additional Support for SessionStorage

Luckily, local storage and session storage use the same interface. To avoid duplicated code, we create a generic StorageService that implements the standard Storage interface and gets the used API. Both services, the LocalStorageService and SessionStorageService, extends from the StorageClass. That makes it possible to inject the needed instance with all the neat features we previously built.

This dependency injection pattern makes it easy for us to mock the code in unit tests without touch the real window storage object. A convenient way for mocking is ng-mockito.

👉 Feel free to check out the sample repository on GitHub.

👉 storage/services/session-storage/session-storage.service.ts Example abstraction of StorageService.

👉 app.module.ts Example usage of both APIs.

Pitfalls & Recap

This service architecture is just a very simple abstraction of both Storage APIs. We do not cover exceptions if the maximum quota exceeds and further write permissions cannot handle. The prefix solution only keeps us safe to avoid naming conflicts. Prefixing is no security feature. Everybody who can run JavaScript on the same origin (SOP) can read the local storage data. When working with data on the client's browser, we still have to care about those security concerns.

If you have any questions or ideas, feel free to drop a line via Twitter or mail@qupaya.com.