Thought Leadership
Jan 18, 2023

Interact With Browser Storage, Type-Safe

Learn how to create a TypeScript Storage abstraction to make interactions with localStorage and sessionStorage type-safe

Interact With Browser Storage, Type-Safe

If you are a web application developer, you have probably interacted with localStorage and sessionStorage. These are objects that seem to be magically present for you no matter what tech stack you are working with. You look at your shiny new web app, it’s there. You look at some legacy code, and it’s there. Working with Angular? It’s there. React? It’s there too. These storage objects are JavaScript standards, available to you no matter what framework you write in, or what browser you run on. However, when working with TypeScript, we want to ensure that we are interacting with storage safely, using TypeScript’s powerful type definitions to keep our interactions with storage bug-free. Let’s learn how.

What is localStorage?

localStorage and its sibling sessionStorage are browser-standard Storage objects available in a web document's origin. It’s a key-value store that allows you to get and set values to be persisted throughout the lifetime of the browser instance. Values in localStorage have no expiration time, while values in sessionStorage values get cleared when the page session ends. For the user of your site, that's when they close their browser tab.

What Can I Do With LocalStorage?

These Storage objects allow you to persist key-value pairs to be referenced at some other point in time when you need them.

You can set items:

localStorage.setItem('name', 'Bob');

You can get items you have set previously:

localStorage.getItem('name'); // returns 'Bob'

You can remove specific items from storage:

localStorage.removeItem('name')';

And you can clear the storage object entirely:

localStorage.clear();

The keys and the values stored with localStorage are always in the UTF-16 string format, which uses two bytes per character. As with objects, integer keys are automatically converted to strings. This means that all values stored in local storage end up as a string.

For more information on localStorage and sessionStorage, see the official documentation from Mozilla.

What About TypeScript?

localStorage and sessionStorage are JavaScript standards. However, a lot of us write our applications in TypeScript now. TypeScript is a programming language developed by Microsoft that is a superset of JavaScript. It is trans-piled down to plain JavaScript at compilation time, and then after that, your application runs pure JavaScript.

The benefit of TypeScript is that it allows developers to enforce types and constructs on their code that normal JavaScript does not support. I can create a variable in TypeScript, and type it as a string.

let name: string = 'Bob';

If I were to try to assign a value that isn’t a string, I would get a compilation error.

let name: string = 'Bob';
name = 10; // compilation error!

TypeScript’s strict typing allows developers to declare the intent of variables, classes, and objects and also helps prevent nasty bugs that can occur in JavaScript’s typeless world.

However, we have a problem here with localStorage and sessionStorage. If you go and reference these values in your TypeScript code, you'll see that they are of type Storage, and their methods return and accept stringvalues and string values only. No exact types are being used here for our stored values.

Where Can Things Go Wrong?

Imagine you are a developer of a TypeScript web application, and you want to store a flag that determines whether a user is 18 years of age or older in localStorage. You get this value from an object typed by the interface User that you have created to represent the attributes of a User. You've done your part to make sure that your User is represented with a clear, type-safe interface so any other developers know what to expect when interacting with a User object.

interface User {
  name: string;
  age: number;
  gender: string;
}

const user: User = {
  name: 'Timmy',
  age: 16,
  gender: 'male'
};

const eighteenOrOlder = user.age >== 18; // evaluates to false

localStorage.setItem('eighteenOrOlder', eighteenOrOlder);

Great! Now you can reference this user’s age status persisted throughout the lifetime of the browser.

Later, a fellow developer writes some new code and needs to reference that boolean flag and use it in an if check.

const eighteenOrOlder = localStorage.getItem('eighteenOrOlder');

if (eighteenOrOlder) {
  ballotService.castVote('William Howard Taft');
}

Uh oh. We’ve got a bug! The localStorage.getItem returned a string, but our developer here assumed it was a boolean because that is what was stored previously. Instead of the value being equal to false, it's equal to 'false'. Doing a truthy check on 'false' returns true because it's not an empty string. The application just allowed this user under 18 years old to vote for William Howard Taft in the next U.S. election! Not good!

The developers of this application have taken great care to make sure they have created robust type constraints that minimize the chance of bugs cropping up, but their interactions with localStorage are not as type-safe as they thought.

Creating a Type-Safe LocalStorage

With the power of TypeScript, we can make our interactions with Storage type-safe. We can create a typed abstraction on top of localStorage to make sure we don't forget that localStorage returns strings. Not only that, but we can also create a contract for our interactions with localStorage, and make sure that the developers of our application only work with the key-value pairs we expect. Our interactions with localStorage will return the types we expect and not strings.

Creating Our LocalStorage Contract

We want to create a typed abstraction on top of JavaScript’s Storage object. First, let's create our contract. What key value pairs do we want to store in LocalStorage?

interface LocalStorageModel {
  eighteenOrOlder: boolean;
  userToken: string;
  userPermissions: string[];
}

Here we have defined our local storage key-value pairs in an interface. These are the only values that our application ever needs to get and set to make our code work. While this looks great already, we can go one step further, and define our keys in an enum. Once we do that, we can use those enum values as the keys for our interface. We'll get to why this is so valuable in a little bit.

enum LocalStorageKey {
  EighteenOrOlder = 'eighteenOrOlder',
  UserToken = 'userToken',
  UserPermissions = 'userPermissions'
}

interface LocalStorageModel {
  [LocalStorageKey.EighteenOrOlder]: boolean;
  [LocalStorageKey.UserToken]: string;
  [LocalStorageKey.UserPermissions]: string[];
}

Now that we’ve got our interface, we want to make sure that developers working on our application follow the rules and only use the values we have defined here. The problem is, localStorage can take any string as a key, and any value no matter the type.

Storage Interface

We want to create a Storage abstraction that implements all the methods on the Storage object and enforces all interactions adhere to the LocalStorageModel above. We'd also like to be able to change our LocalStorageModelinterface and have our Storage abstraction adjust to new values without having to go and change the implementation of the abstraction. This is where TypeScript's generics come in.

interface iStorageService<T> {
  get<K extends keyof T>(key: K): T[K] | null;

  set<K extends keyof T>(key: K, value: T[K]): void;

  remove<K extends keyof T>(key: K): void;

  clear(): void;
}

Wow, that looks like complete nonsense. What are all these letters everywhere?

This interface is making use of generics. Generics allow us to have our storage interface work with a variety of types, not just one type. This value T is a stand-in for the defined type that we will provide at the time of class instantiation and method utilization. T could be a string, it could be an array of strings, it could be an Object, it could be anything.

We’ve also got this K extends keyof T defined here. What does this mean?

K represents a given key in our local storage. All of these methods, get, set and delete take a key, just like localStorage methods do.

K extends keyof T means "K is a key contained in whatever type T is."

In this case, we are going to have T be our LocalStorageModel. So K extends keyof T in our case means "K is a key contained in LocalStorageModel."

Each of our methods takes a key: K, contained in type T, and some of them accept or return a value typed as T[K]. T[K] means that we are returning a value that has a type equal to the type of the value stored at key K on object T.

Let’s use our LocalStorageModel as an example. We've got a key userToken that has a value typed as a string. Plugging our LocalStorageModel into the generic iStorageService means that:

  • T is LocalStorageModel
  • K extends keyof T is our key userToken
  • T[K] for key userToken is the type string

Remember that enum we created before defining our set of local storage keys? That is going to guarantee that our key Kis the same every single time. Using the enum prevents a scenario like this…

localStorage.setItem('userToken', 'some_token');

// Typo! Wrong key! This is going to return null!
const token = localStorage.getItem('userTokn');

If we force interactions with local storage to use predefined keys, there will never be a typo in the key’s value to cause bugs in our code.

localStorage.setItem(LocalStorageKey.UserToken, 'some_token');

const token = localStorage.getItem(LocalStorageKey.UserToken);

Great, But This Interface Doesn’t Do Anything

We’ve got our type constraints on our interface, but we haven’t actually implemented anything yet. Now that we’ve got our interface, let's make a class that implements the interface iStorageService that we have defined here.

class StorageService<T> implements iStorageService<T> {
    ...
}

There’s that T again. We've created a class here that must implement the methods defined in interface iStorageService, and it must implement them without breaking the type constraints. The value for T is going to be passed in when we instantiate this class.

const storageService = new StorageService<LocalStorageModel>();

You’ll see that we passed in our interface LocalStorageModel to take the place of the generic value T. Now, any time StorageService references T, it will be referencing LocalStorageModel.

Now we need to implement our methods so we are adhering to the interface we are implementing.

First, we’re going to need an instance of Storage to interact with. We'll expect the consumer of our class to provide us with the instance of Storage we need.

class StorageService<T> implements iStorageService<T> {
  constructor(private readonly storage: Storage) {}
}

The great thing about our generic wrapper here is that it will work with any storage instance. It’s not specific to localStorage. Since sessionStorage is also of type Storage, we can use sessionStorage in this abstraction just the same! No need to write two separate implementations for localStorage and sessionStorage. We will use it like this...

const localStorageService = new StorageService<LocalStorageModel>(
  window.localStorage
);

const sessionStorageService = new StorageService<SessionStorageModel>(
  window.sessionStorage
);

Next, let’s implement our set method.

class StorageService<T> implements iStorageService<T> {
  constructor(private readonly storage: Storage) {}

  set<K extends keyof T>(key: K, value: T[K]): void {
    this.storage.setItem(key.toString(), JSON.stringify(value));
  }
}

Here we are taking a key K and a value T[K] and putting them in our storage object. Remember that we are creating an abstraction on top of Storage, but the abstraction is using the storage instance directly, so we have to make sure we provide string keys and values as expected. We can call toString() on our key, and JSON.stringify() on our value to store it properly. We use the JSON.stringify() for the value because our value could be an object or an array and we want it serialized properly.

Now let’s implement our get method.

class StorageService<T> implements iStorageService<T> {
  constructor(private readonly storage: Storage) {}

  set<K extends keyof T>(key: K, value: T[K]): void {
    this.storage.setItem(key.toString(), JSON.stringify(value));
  }

  get<K extends keyof T>(key: K): T[K] | null {
    const value = this.storage.getItem(key.toString());

    if (value === null || value === 'null'
      || value === undefined || value === 'undefined') {
      return null;
    }

    return JSON.parse(value);
  }
}

You’ll see that we have a few things going on here. First, we have to get the value from local storage. Once again, we have to make sure we turn our key into a string. Since we set using the value as a string, we need to get using the value as a string, or else we could get the wrong value back.

Next, we want to check for possible empty values. Checking for all these falsy values, as well as their string versions we can make sure we returned a unified "empty" value, and don't risk returning a string version of null or undefined. Those strings will return true for any falsy checks we do. So let's just return null, and if we call get and the value is empty, just like Storage does in plain JavaScript. Notice we are not doing a simple truthy check here, because 0 and empty string might be valid entries in local storage that we don’t want to be turned into null. We need to be more exact in our checks for what constitutes an empty value.

Finally, the value we return we want to callJSON.parse() on it. By doing this, we can allow for the storage of JavaScript objects and arrays, as well as strings and booleans. String values 'true' and 'false' will be turned into boolean, and string representations of arrays will become arrays. This gets our typings back into our values.

Finally, let’s implement the remove and clear methods.

class StorageService<T> implements iStorageService<T> {
  constructor(private readonly storage: Storage) {}

  set<K extends keyof T>(key: K, value: T[K]): void {
    this.storage.setItem(key.toString(), JSON.stringify(value));
  }

  get<K extends keyof T>(key: K): T[K] | null {
    const value = this.storage.getItem(key.toString());

    if (value === null || value === 'null'
      || value === undefined || value === 'undefined') {
     return null;
    }

    return JSON.parse(value);
  }

  remove<K extends keyof T>(key: K): void {
    this.storage.removeItem(key.toString());
  }

  clear(): void {
    this.storage.clear();
  }
}

All methods have been implemented! We now have a generic StorageService that can be given any Storage object, and any interface to serve as its typing contract. Interactions with our storage service will now be type-safe.

interface LocalStorageModel {
  [LocalStorageKey.EighteenOrOlder]: boolean;
  [LocalStorageKey.UserToken]: string;
  [LocalStorageKey.UserPermissions]: string[];
}

const localStorageService = new StorageService<LocalStorageModel>(
  window.localStorage
);

// All good!
localStorageService.set(LocalStorageKey.UserToken, 'some_token');

/**
 * Compilation error!
 * Argument of type 'number' is not assignable to parameter of type 'string'
 */
localStorageService.set(LocalStorageKey.UserToken, 12345);

/**
 * Compilation error!
 * Argument of type 'string' is not assignable to parameter of type 'boolean'.
 */
localStorageService.set(LocalStorageKey.EighteenOrOlder, 'true'); 

/**
 * Compilation error!
 * Argument of type '"some other key"' is not assignable to parameter of type 'keyof LocalStorageModel'.
 */
localStorageService.set('some other key', 'hello world');

/**
 * Compilation error!
 * Type 'string' is not assignable to type 'number'.
 */
const token: number = localStorageService.get(LocalStorageKey.UserToken);

You can even get more complex in your usage!

If your LocalStorageModel looked like this…

interface LocalStorageModel {
  [LocalStorageKeys.UserData]: {
    name: string,
    age: number,
    gender: string
  }
}

and you provided an object for userData that was missing some keys, the compiler will catch that too. You haven’t adhered to the typing set by your interface!

const localStorageService = new StorageService<LocalStorageModel>(
  window.localStorage
);

const user = {
  name: 'Bob',
  age: 32
};

/**
 * Compilation error!
 * Property 'gender' is missing in type '{ name: string, age: number }'.
 */
localStorage.set(LocalStorageKeys.UserData, user);

Handling a Rare Edge Case

There’s one more thing we need to take care of, and it’s something often forgotten in web applications. Sometimes localStorage and sessionStorage may not be available. This could be due to a strange browser the user is running on, or they could have disabled the usage of storage in their browser settings. In this case, accessing window.localStorage or the various get/set/remove methods on localStorage will throw an exception because localStorage is not present. We need to handle this exception.

class LocalStorageService extends StorageService<LocalStorageModel> {
  public localStorageEnabled: boolean;

  constructor() {
    let storage: Storage | undefined;

    try {
        storage = window.localStorage;
    } catch (e) {
        storage = undefined;
    }

    super(storage);

    this.localStorageEnabled = !!storage;
  }
}

With this LocalStorageService, we are going to catch the exception thrown when accessing window.localStoragewhen it's not available. We'll then provide an undefined storage value to the constructor of the StorageService.

We need to make a change to our StorageService now. If our storage value is undefined, then all of our get/set/remove methods are going to throw an exception. We want our application to continue as normal and not worry about the lack of localStorage. If it's not there for us, it's not there, but we don't want to worry about handling its presence in our code.

class StorageShim {
  constructor(private hash: any = {}) {}

  getItem = (key: any) => this.hash[key];
  setItem = (key: any, value: any) => (this.hash[key] = value);
  removeItem = (key: any) => delete this.hash[key];
  clear = () => (this.hash = {});
}

class StorageService<T> implements IStorageService<T> {
  constructor(
    private readonly storage: Storage = (new StorageShim() as any)
  ) {}
}

Here we’ve created a fake storage shim that just acts like localStorage but isn't. It implements all the methods that are found in an instance of Storage. If the value provided in the constructor of our StorageService is falsy, we'll default to the shim. This will rarely happen, because most users run modern browsers that support storage, and they don't disable localStorage in their browser settings. However, it's always good to have our application prepared to handle anything!

Final Thoughts

Creating this abstraction on top of your interactions with local storage will ensure that you are using the correct keys and value types, and will prevent you from making those hard-to-track-down JavaScript typing bugs. We can use the power of TypeScript, with its interfaces, enums, and generics to create a reusable storage abstraction that can handle any storage model contract, and will be looking out for you as you write your code.

. . .
Article Summary
Learn about the critical CVE-2024-4577 OS command injection vulnerability affecting PHP versions 5.x to 8.x, its risks, steps to reproduce, and how to protect your systems with updates and extended support.
Author
Kevin Longmuir
Software Architect
Related Articles
Open Source Insights Delivered Monthly

By clicking “submit” I acknowledge receipt of our Privacy Policy.

Thanks for signing up for our Newsletter! We look forward to connecting with you.
Oops! Something went wrong while submitting the form.