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.
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#
npm i @dmytromykhailiuk/typed-local-storage
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.
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.
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:
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#
function createLocalStorage<T>(
key: string,
options?: {
initialValue?: T;
isString?: boolean;
groups?: LocalStoragesGroup[];
onParseError?: (error: unknown, raw: string) => void;
},
): LocalStorage<T>;
Options#
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.
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.
Groups this storage joins on creation — shorthand for calling
group.add(storage) afterwards. See
Groups.
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.
Read the current value. A missing key yields
initialValue (or undefined);
corrupted JSON reports through onParseError and
falls back the same way. Never throws.
Serialize and write the value, then notify subscribers of this instance.
Read-modify-write in one call:
set(fn(get())). The callback receives the same
nullability as get().
Reset to initialValue — or remove the key when
the storage has none.
Whether the key currently exists in storage.
Listen for changes — local writes and other tabs' writes. Returns an unsubscribe function. See Subscriptions.
The underlying storage key, as passed at creation.
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:
const cart = createLocalStorage("cart", { initialValue: [] as CartItem[] });
cart.set([{ id: 1, qty: 2 }]);
// localStorage["cart"] === '[{"id":1,"qty":2}]'
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:
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:
- Seeds the key at creation — written to storage immediately, but only when the key is absent.
-
Defines what
clear()means — reset to it, rather than remove the key. -
Backs
get()— a missing or unparseable value falls back to it, which is whyget()can promise a plainT.
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
"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).
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.
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 instance —
set,updateandclearnotify synchronously after writing. -
Other tabs — the browser's
storageevent is translated into the same callback. The event only fires in other documents, so a write is delivered exactly once everywhere — no double notifications.
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.
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":
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.
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.
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.
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.
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:
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