Using Typescript with electron-store
Tags: typescript, react, javascript, tutorial
Published: 2022-01-17 21:27:54

When building Electron apps, you’ll inevitably need to reach for a storage solution to persist your data across sessions (like saving user data or preferences). You can rely on the web’s `localStorage` or even cookies - but sometimes you want something a little more robust. For Electron, the easiest option is **[electron-store](https://www.npmjs.com/package/electron-store)**, a key-value storage very similar to localStorage.

# Why use electron-store?

There are a few clear reasons:

- Local Storage and cookies require you to `JSON.stringify` any complex data sets. So if you have an object, array, or even a boolean — it’ll ultimately need to be converted to JSON and back. electron-store lets you store complex data directly and handles the serialization to JSON for you.
- electron-store is built with ajv, a data validation library (similar to Yup). This allows you to set a specific schema for your data and have it immediately validated when stored.
- The other alternative to localStorage on Electron is basically using NodeJS and it’s filesystem APIs to write data to disk (like JSON to a `.json` file).
- And of course, you could always leverage an actual database locally, but this would require a lot of setup and integration into Electron.

# Why Typescript?

When you use Electron store inside your app to `get()` data, the variables will be untyped. So when you want to `const user = store.get('user');` and then see what properties `user` has — it’ll be `unknown` type. If you inspect your `store` type in your IDE, you’ll notice it’s a `Record<>` type with an `unknown` type passed in.

An easy workaround for this is “casting” the type:

```tsx
const user = store.get('user') as UserData
```

This forces Typescript to use the `UserData` type for `user`. This works fine and won’t trigger compilation errors — but ultimately adds extra work each time you grab data.

Instead, when creating the store we can pass in the types, and that’ll trickle down each time we use `store.get()`. This will make development much easier, and help Typescript validate your code deeper down the call stack.

# Using electron-store

Let’s setup a new store with a defined schema and TS types. We’ll be creating a data store that keeps “install” data. To give you context, my app is a “Blender Launcher”, so I need to keep track of the different versions and locations of Blender installations. The data type reflects this, with properties for things like “version”.

Create a file to contain the store and your schema. This should live in near the “main” process file (usually `src/main/main.ts`):

```tsx
// src/main/store.ts
import Store from 'electron-store'
import { JSONSchemaType } from 'ajv'
import { InstallData } from 'renderer/common/types'

// Define your schema in TS
// This is essentially the shape/spec of your store
export type SchemaType = {
  installs: InstallData[]
}

// Define your schema per the ajv/JSON spec
// But you also need to create a mirror of that spec in TS
// And use the type here
const schema: JSONSchemaType<SchemaType> = {
  type: 'object',
  properties: {
    installs: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          version: { type: 'string' },
          path: { type: 'string' },
          type: { type: 'string' },
          tags: {
            type: 'array',
            items: { type: 'string' },
          },
        },
        required: ['path', 'tags', 'type', 'version'],
      },
    },
  },
  required: ['installs'],
}

// We define the keys we'll be using to access the store
// This is basically the top-level properties in the object
// But electron-store supports dot notation, so feel free to set deeper keys

// We set the type like this so when we use `store.get()`
// It'll use the actual keys from store and infer the data type
export const STORE_KEYS: { [key: string]: keyof SchemaType } = {
  INSTALLS: 'installs',
  // PREFERENCES: 'preferences',
  // PROJECTS: 'projects',
}

// Create new store with schema
// And make sure to pass in schema TS types
// If you don't do this, when you use `store.get/set`, the return type will be unknown.
// Not sure why this has lint error. But get/set methods return proper types so...
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = new Store<SchemaType>({ schema })

export default store
```

Here are the types for references (and maybe better visualization of the data structure):

```tsx
// types.ts
export const TAGS = {
  Android: 'Android',
  MacOS: 'MacOS',
  Windows: 'Windows',
}
export type TagsEnum = keyof typeof TAGS

export type InstallData = {
  /**
   * Version of app
   */
  version: string
  /**
   * Path to Blender on computer
   */
  path: string
  /**
   * Is it Release, Beta, etc?
   */
  type: string
  /**
   * Maybe not needed? Maybe if versions have modules others don't?
   */
  tags: TagsEnum[]
}
```

Now that we have a store, we can use it to `get()` and `set()` some data. This happens inside the “main” Electron process, the place where we can use “server-side” APIs like NodeJS. In the main process, we create an IPC event handler. This will allow our client-side Electron (aka React) talk to our “server-side”:

```tsx
import { app, BrowserWindow, shell, ipcMain, dialog } from 'electron'
import { InstallData } from 'renderer/common/types'
import store, { STORE_KEYS } from './store'

ipcMain.handle('store:install', async (_, newInstall: InstallData) => {
  // We grab the previous data
  const prevInstalls = store.get(STORE_KEYS.INSTALLS)
  // And merge old data with new data
  // We also do a quick null check and pass empty array if so
  const result = store.set(STORE_KEYS.INSTALLS, [
    ...(prevInstalls || []),
    newInstall,
  ])
  return result
})
```

Then we can create a “bridge” from backend (”main”) to frontend (React) using the IPC:

```tsx
// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electron', {
  storeInstall: async () => ipcRenderer.invoke('store:install'),
})
```

And inside our React app we can just:

```tsx
const installData: InstallData = {
  version: '2.0',
  //etc
}
// Grab the method we passed into `window.electron`
// Try typing `window.electron.` and seeing what autocomplete pops up
const saveInstall = await window.electron.storeInstall(installData)
```

> Want to see the code in it's entirety? Check out [the project on Github here.](https://github.com/whoisryosuke/blender-hub2)

# References

- [Ajv JSON schema validator](https://ajv.js.org/guide/typescript.html)
  - See how TS works with ajv.
- [object - Understanding JSON Schema 2020-12 documentation](https://json-schema.org/understanding-json-schema/reference/object.html#properties)
  - JSON schema
- [https://github.com/sindresorhus/electron-store/issues/49](https://github.com/sindresorhus/electron-store/issues/49)
  - Links to [internal test](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5e58f71c5e96c7c4bfc316f3b5b3f39124027609/types/electron-store/electron-store-tests.ts#L1) that shows how to pass types to electron-store