typed-local-storage
typed-local-storage 1.0.0

Typed localStorage with initial values, groups and cross-tab subscriptions. SSR-safe, dependency-free.

Contents

Contents

typed-local-storage

Typed localStorage with initial values, groups and cross-tab subscriptions — a few hundred bytes, no dependencies.

Built for apps that keep a lot in localStorage — settings, drafts, filters, tokens, per-feature caches. At that scale two things start to hurt: typing, because every read is an untyped string you have to parse and trust; and management, because clearing state means either hunting down keys one by one or reaching for localStorage.clear() — which wipes the whole origin, including what had to survive.

This library answers both. Each storage is declared once, owns one key, and carries its value type in its signature — no string keys scattered around the codebase, no JSON.parse(localStorage.getItem(...) ?? "null") ceremony, no silent crashes on corrupted data. And groups make cleanup targeted: related storages are cleared together, in one call, each by its own rules — everything else stays untouched.

typed end to end never throws on bad data cross-tab subscriptions SSR-safe zero dependencies
Never touch localStorage directly

Once keys are owned by storages, the raw localStorage object is the escape hatch that breaks every guarantee. A direct setItem bypasses typing, serialization and this tab's subscribers; a direct removeItem silently undoes seeding. And localStorage.clear() is the worst of them — it levels the whole origin: every storage, the group registries, and keys that had to survive. Every operation has a safe counterpart — set / update to write, storage.clear() to reset one storage, group.clear() to reset a related slice. If code outside the library still writes a key (a legacy module, another script), that is what string mode and onParseError are for — but it should be the exception, not the habit.

Getting started

Install#

sh
npm i @dmytromykhailiuk/typed-local-storage
Requirements

None. No runtime dependencies, no framework — plain TypeScript that works in any browser and in Node ≥ 18. Ships ESM and CJS with type declarations for both.

Quick start#

Create a storage once at module scope. The value type is inferred from initialValue.

ts
import { createLocalStorage } from "@dmytromykhailiuk/typed-local-storage";

const settings = createLocalStorage("app:settings", {
  initialValue: { theme: "dark", fontSize: 14 },
});

settings.get();                       // { theme: "dark", fontSize: 14 } — typed
settings.set({ theme: "light", fontSize: 16 });
settings.update((s) => ({ ...s, fontSize: s.fontSize + 1 }));
settings.clear();                     // back to the initial value

const unsubscribe = settings.subscribe((value) => {
  // fires on every write through this instance —
  // and when another tab writes the same key
  document.body.dataset.theme = value.theme;
});

Because an initialValue was given, get() returns T — never undefined. Without one it returns T | undefined, and the callbacks of update and subscribe follow. See TypeScript.

One key, one owner#

The library's one opinion: a localStorage key should have exactly one owner. You declare that owner — the storage object — in one place, give it the type, and import it everywhere else.

tsstorage.ts — the single place where keys live
import { createLocalStorage } from "@dmytromykhailiuk/typed-local-storage";

export const session = createLocalStorage<Session>("app:session");
export const drafts = createLocalStorage("app:drafts", {
  initialValue: [] as Draft[],
});

// anywhere else in the app
import { drafts } from "./storage";
drafts.update((list) => [...list, draft]);

Creating a second storage with a name that is already registered throws — two storages writing one key with different types is a bug worth failing loudly on:

ts
createLocalStorage("app:session");
createLocalStorage("app:session"); // Error: Storage "app:session" already exists

The returned object is frozen (Object.freeze), so its methods cannot be monkey-patched or reassigned — what you export from storage.ts is exactly what every consumer gets.

Reference

createLocalStorage#

ts
function createLocalStorage<T>(
  key: string,
  options?: {
    initialValue?: T;
    isString?: boolean;
    groups?: LocalStoragesGroup[];
    onParseError?: (error: unknown, raw: string) => void;
  },
): LocalStorage<T>;

Options#

initialValueT

Written to storage at creation — but only when the key is absent. Restored by clear(). Returned by get() whenever the key is missing, and it narrows the return type of get() from T | undefined to T.

A stored 0, false or "" is a value, not an absence — it is never overwritten. See Initial values.

isStringboolean

Store the value as a raw string instead of JSON. Inferred automatically when initialValue is a string; set it explicitly for string storages created without one. See String mode.

groupsLocalStoragesGroup[]

Groups this storage joins on creation — shorthand for calling group.add(storage) afterwards. See Groups.

onParseError(error: unknown, raw: string) => void

Called when a stored value cannot be parsed as JSON — get() then falls back to initialValue. Defaults to a console.warn. See Error handling.

The storage object#

Everything below is a stable reference on a frozen object. V stands for what get() returns — T when the storage has an initialValue, T | undefined otherwise.

get() => V

Read the current value. A missing key yields initialValue (or undefined); corrupted JSON reports through onParseError and falls back the same way. Never throws.

set(value: T) => void

Serialize and write the value, then notify subscribers of this instance.

update(fn: (value: V) => T) => void

Read-modify-write in one call: set(fn(get())). The callback receives the same nullability as get().

clear() => void

Reset to initialValue — or remove the key when the storage has none.

hasValue() => boolean

Whether the key currently exists in storage.

subscribe(listener: (value: V) => void) => () => void

Listen for changes — local writes and other tabs' writes. Returns an unsubscribe function. See Subscriptions.

keystring

The underlying storage key, as passed at creation.

initialValueT | undefined

The initial value, as passed at creation.

Serialization#

Values go through JSON.stringify / JSON.parse — objects, arrays, numbers, booleans and null round-trip as you would expect:

ts
const cart = createLocalStorage("cart", { initialValue: [] as CartItem[] });
cart.set([{ id: 1, qty: 2 }]);
// localStorage["cart"] === '[{"id":1,"qty":2}]'
JSON limits apply

What JSON cannot carry, the storage cannot either: Date becomes a string, Map / Set / undefined fields are lost, functions don't serialize. Convert at the boundary — store timestamps, entries arrays, plain objects.

String mode#

A JSON-encoded string is stored with quotes — "\"dark\"" — which is noisy in devtools and breaks interop with code that wrote the key before this library. String mode stores the value raw. It turns on automatically when initialValue is a string, or explicitly with isString: true:

ts
const theme = createLocalStorage("app:theme", { initialValue: "dark" });
// localStorage["app:theme"] === "dark"        (not "\"dark\"")

const token = createLocalStorage<string>("app:token", { isString: true });
token.set("eyJhbGciOi…");
// localStorage["app:token"] === "eyJhbGciOi…" — readable by anything

In string mode nothing is parsed on read either, so a value written by other code — whatever it contains — comes back verbatim and can never trigger onParseError.

Initial values#

An initialValue does three things:

  1. Seeds the key at creation — written to storage immediately, but only when the key is absent.
  2. Defines what clear() means — reset to it, rather than remove the key.
  3. Backs get() — a missing or unparseable value falls back to it, which is why get() can promise a plain T.
ts
const counter = createLocalStorage("counter", { initialValue: 0 });

// key absent           → "0" is written; get() === 0
// a session stored 7   → seeding skipped; get() === 7
// a session stored 0   → kept as 0 — falsy is a value, not an absence
Absence means the key, not the value

"Absent" is getItem(key) === null — the key does not exist. A stored 0, false, "" or null is an existing value and is never overwritten by seeding.

What emptying a storage means, in both configurations:

with initialValue without
clear() writes initialValue back removes the key
get() afterwards initialValue undefined

Error handling#

Storage is shared, hand-editable state — a browser extension, an old app version or a stray devtools session can leave anything in it. get() therefore never throws: unparseable JSON is reported through onParseError and the call falls back to initialValue (or undefined).

ts
const cart = createLocalStorage("cart", {
  initialValue: [] as CartItem[],
  onParseError: (error, raw) => report(error, { raw }),
});

localStorage.setItem("cart", "{oops"); // someone corrupted it
cart.get(); // [] — the initial value; onParseError was called with "{oops"

The default handler is a console.warn naming the library and the raw value — corruption is visible in development even when you configure nothing.

Writes can still throw

set() propagates storage-level failures — most notably QuotaExceededError when the origin's storage is full. That is a real error about the current write, not stale data, and swallowing it would mean silently losing what the user just did.

Subscriptions#

subscribe delivers every change of the key to one callback, wherever the change came from:

  • This instanceset, update and clear notify synchronously after writing.
  • Other tabs — the browser's storage event is translated into the same callback. The event only fires in other documents, so a write is delivered exactly once everywhere — no double notifications.
ts
const theme = createLocalStorage("app:theme", { initialValue: "dark" });

const unsubscribe = theme.subscribe((value) => {
  document.body.dataset.theme = value; // value: string — typed like get()
});

theme.set("light");
// …and when another tab calls theme.set("dark"), this tab's callback
// fires with "dark" via the storage event.

unsubscribe();

The window-level storage listener is attached lazily on the first subscribe and detached when the last subscriber leaves — a storage nobody listens to costs nothing. A listener that throws is isolated (reported via console.error) and never blocks the other listeners.

Storage.clear() elsewhere

A storage event with key === null means another tab called localStorage.clear() — that wipes this key too, so subscribers are notified with the fallback value.

Groups#

When an app stores many keys, "clear the user's data" has two bad answers: localStorage.clear(), which levels the whole origin — theme, language, consent flags, everything that should have survived — and clearing keys one by one, a list that silently drifts out of date every time a feature adds a key.

A group is the middle ground: storages that belong together are declared together, and reset together with one call — nothing outside the group is touched. The classic case — "clear everything user-scoped on logout":

ts
import {
  createLocalStorage,
  createLocalStoragesGroup,
} from "@dmytromykhailiuk/typed-local-storage";

const userScoped = createLocalStoragesGroup("app:user-scoped");

const session = createLocalStorage<Session>("app:session", {
  groups: [userScoped],
});
const drafts = createLocalStorage("app:drafts", {
  initialValue: [] as Draft[],
  groups: [userScoped],
});

// on logout
userScoped.clear(); // session removed, drafts reset to []

clear() applies each member's own semantics: a member with an initialValue is reset to it, a member without one is removed. Members are cleared through their instances, so their subscribers are notified.

Registrations survive reloads#

The group persists its member list — key and initial value — in storage under its own name. On the next session, clear() also covers keys whose createLocalStorage call didn't run this time (a lazy route, an unvisited feature) — code paths that didn't execute can't leak stale data past a logout.

A group owns a key of its own

createLocalStoragesGroup(name) stores that member list under name — the name participates in the same global registry as every storage, so it must be unique too, and you will see it in devtools next to your data keys.

One storage can belong to any number of groups — pass several in groups, or call group.add(storage) after creation; both do the same thing.

SSR & fallbacks#

Where localStorage is missing or throws on access — Node during SSR, sandboxed iframes, browsers with cookies disabled — every storage transparently runs against a shared in-memory fallback. The entire API keeps working; values simply live for the lifetime of the process.

tsthe same module, imported on the server
const session = createLocalStorage<Session>("app:session", {
  initialValue: guest,
});

session.get(); // guest — served from the in-memory fallback, no crash
session.set(user);
session.get(); // user — persists for the lifetime of the process

The fallback is shared across all storages in the process, so groups and cross-storage flows behave the same as in a browser. Subscriptions still deliver local writes; there are simply no other tabs to hear from.

No environment guards in your code

The point is that storage.ts needs no typeof window checks and no dynamic imports — the module is safe to import anywhere, and the environment decision is made once, inside the library.

TypeScript#

Whether the storage can be empty is part of its type. createLocalStorage has two overloads: with an initialValue the storage can never observe an absent value, so get() returns T; without one it returns T | undefined. The callbacks of update and subscribe follow the same rule.

ts
const counter = createLocalStorage("counter", { initialValue: 0 });
counter.get();                // number — no undefined to handle
counter.update((v) => v + 1); // v: number

const session = createLocalStorage<Session>("app:session");
session.get();                // Session | undefined
session.update((v) => v ?? emptySession); // v: Session | undefined
session.subscribe((v) => {    // v: Session | undefined
  if (v) render(v);
});

The value type comes from inference off initialValue — including literal widening, so initialValue: "dark" infers string… or from the explicit parameter (createLocalStorage<Session>(…)) when there is nothing to infer from. To keep a union type with an inferred initial value, annotate it:

ts
type Theme = "dark" | "light";

const theme = createLocalStorage<Theme>("app:theme", { initialValue: "dark" });
theme.set("light"); // ✓ typed
theme.set("blue");  // ✗ Type '"blue"' is not assignable to type 'Theme'

Exports#

Values

createLocalStorage · createLocalStoragesGroup

Types

LocalStorage · LocalStorageOptions · LocalStoragesGroup