typed-storage

July 26, 2026 · View on GitHub

Type-safe localStorage and sessionStorage with a signal-like API, TTL support, cross-tab sync, schema migrations, and automatic fallback to memory when storage is unavailable.

const appStorage = createStorage({
  theme: 'dark' as 'dark' | 'light',
  language: 'es' as 'es' | 'en',
  fontSize: 16,
});

appStorage.theme.set('light');   // ✅ typed — only 'dark' | 'light' accepted
appStorage.theme.set('purple');  // ❌ TypeScript error at compile time
console.log(appStorage.theme()); // 'light' — persisted across reloads

✨ Features

  • Type-safe — TypeScript infers types from your schema automatically
  • Signal-like API — read with signal(), write with signal.set(value)
  • TTL / expiration — keys expire automatically after a defined time
  • Cross-tab sync — changes in one tab reflect in others via StorageEvent
  • Schema migrations — safely transform data when your schema changes
  • Memory fallback — works even when localStorage is unavailable (Safari private mode, quota exceeded)
  • Prefix namespacing — avoid key collisions across apps or modules
  • sessionStorage support — opt in per schema
  • onChange — subscribe to value changes with a callback
  • batch() — update multiple keys in a single call
  • computed() — derive reactive values from one or more signals
  • destroy() — completely remove scoped/temporary data
  • routeOverrides — different values per route/page, with automatic sync via setRoute()
  • Options validation — clear errors for invalid configuration, thrown early
  • Zero dependencies — pure TypeScript, no external packages

📦 Installation

npm install typed-storage
# or
pnpm add typed-storage

🚀 Basic Usage

import { createStorage } from 'typed-storage';

const appStorage = createStorage({
  theme: 'dark' as 'dark' | 'light',
  language: 'es' as 'es' | 'en' | 'fr',
  fontSize: 16,
  sidebarOpen: true,
});

// Read
console.log(appStorage.theme());       // 'dark'

// Write — persists to localStorage automatically
appStorage.theme.set('light');
console.log(appStorage.theme());       // 'light'

// Reset to initial value
appStorage.theme.reset();
console.log(appStorage.theme());       // 'dark'
// reset() applies the exact same protections as set(): encrypt, compress,
// TTL, quota handling, and conflict resolution timestamps all work identically

// Check if key exists in storage
appStorage.theme.has();                // true

// Remove key from storage
appStorage.theme.remove();
appStorage.theme.has();                // false

// Subscribe to changes
appStorage.theme.onChange((newValue) => {
    console.log('theme changed to:', newValue);
});

// Clear all keys in the schema (resets to initialValue, keys still exist)
appStorage.clear();

📦 Batch updates

Update multiple keys in a single call instead of calling .set() on each one separately:

const appStorage = createStorage({
    theme: 'dark' as 'dark' | 'light',
    fontSize: 16,
    language: 'es' as 'es' | 'en',
});

// Instead of this:
appStorage.theme.set('light');
appStorage.fontSize.set(20);
appStorage.language.set('en');

// Do this:
appStorage.batch({
    theme: 'light',
    fontSize: 20,
    language: 'en'
});

Useful for forms with several fields saved at once (e.g. a "Preferences" screen with a single "Save" button), where writing multiple .set() calls is repetitive.

appStorage.batch({
    theme: 'light',   // updated
    fontSize: 20       // updated
    // language is not included — stays unchanged
});

Each updated key still fires its own onChange callback and persists individually.


🧮 Computed values

Derive a reactive value from one or more existing signals, without duplicating calculation logic across your code:

import { createStorage, computed } from 'typed-storage';

const userStorage = createStorage({
    firstName: 'Jean',
    lastName: 'Haro'
});

const fullName = computed(
    [userStorage.firstName, userStorage.lastName],
    (first, last) => `${first} ${last}`
);

fullName(); // → 'Jean Haro'

userStorage.firstName.set('Jeanpierre');
fullName(); // → 'Jeanpierre Haro' — recalculated automatically

Another example — a cart total that always reflects the current state:

const cartStorage = createStorage({
    items: [] as { price: number; quantity: number }[]
});

const total = computed(
    [cartStorage.items],
    (items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

cartStorage.items.set([{ price: 10, quantity: 2 }]);
total(); // → 20

cartStorage.items.set([{ price: 10, quantity: 5 }]);
total(); // → 50 — recalculated automatically

computed() doesn't cache anything internally — it recomputes on every call by reading the current value of each source signal. This keeps it simple and always correct, at the cost of not being optimized for extremely hot loops.


🗑️ Scoped / temporary storage with destroy()

Some data only makes sense while the user is on a specific page or component — search filters, a form draft, table selection state. destroy() completely removes those keys from storage, unlike reset() (which keeps the key but resets its value) or clear() (which resets all keys but keeps them present).

const searchFilters = createStorage({
    category: '',
    priceRange: [0, 100]
}, { prefix: 'products-page' });

// User filters, searches, browses...
searchFilters.category.set('electronics');

// User leaves the "Products" page
searchFilters.destroy();
// → localStorage['products-page:category'] no longer exists at all
// → coming back later starts clean

destroy() vs reset() vs clear()

// reset(key) — goes back to initialValue, key still exists in storage
storage.theme.reset();
// localStorage['app:theme'] = '"dark"' — still there
// storage.theme() → 'dark' (initialValue)

// destroy() (on the whole StorageResult) — removes every key completely
storage.destroy();
// localStorage['app:theme'] — no longer exists
// storage.theme() → 'dark' (initialValue, in memory only)

// clear() — calls reset() on every key in the schema
storage.clear();
// same as reset(), but for every key at once — keys still exist

When to use destroy()

✅ Good for:
   - Search filters, form drafts, temporary UI state
   - Data that should always start "clean" on a fresh visit

❌ Not for:
   - Data you want to reuse between visits (use `ttl` for that instead —
     a short-lived cache that still survives navigation)
   - User preferences (theme, language) — those should persist indefinitely

If you want data to survive between visits but expire after a while (e.g. a product list cache), use ttl instead of destroy() — that's a "stale-while-revalidate" pattern: show the cached value immediately, then refresh it in the background.

Automatic integration with Angular's ngOnDestroy / React's useEffect cleanup (so you don't have to call destroy() manually) is planned for the framework wrappers.


🧭 Different values per route with routeOverrides

Some values should differ depending on which page the user is on — for example, a different theme on the landing page than in the dashboard. routeOverrides maps routes to specific key values, applied through setRoute().

const appStorage = createStorage({
    theme: 'dark' as 'dark' | 'light',
}, {
    prefix: 'app',
    routeOverrides: {
        '/': { theme: 'dark' },
        '/about': { theme: 'light' }
    }
});

appStorage.setRoute('/about');
appStorage.theme(); // → 'light'

appStorage.setRoute('/');
appStorage.theme(); // → 'dark'

If the current route has no entry in routeOverrides, setRoute() does nothing — the "normal" value (whatever is currently stored) is used as-is.

Removing a key for a specific route with null

Use null as the override value to make a key disappear from storage entirely while on that route:

const appStorage = createStorage({
    currency: 'USD',
}, {
    prefix: 'shop',
    routeOverrides: {
        '/checkout': { currency: null } // force re-selection at checkout, for safety
    }
});

appStorage.currency.set('EUR');
appStorage.setRoute('/checkout');
// → localStorage['shop:currency'] is removed
// → appStorage.currency() returns 'USD' (the initialValue) while on this route

This is useful when a value should never be silently "remembered" on a specific page, even if it persists everywhere else.

⚠️ null is destructive — it's not reversible

Once setRoute() removes a key because of a null override, the previous value is gone completely — it's not "remembered" for later. Navigating to a route with no entry in routeOverrides does not restore what was there before; it simply leaves the key as whatever it currently is (removed, in this case), falling back to initialValue.

routeOverrides: {
    '/contact': { theme: null }
    // '/dashboard' has no entry — no override at all
}

storage.theme.set('light');      // user picks 'light'
storage.setRoute('/contact');    // → key removed, theme() = 'dark' (initialValue)
storage.setRoute('/dashboard');  // → no override, does nothing
                                  // → but the key is STILL removed from the previous step
                                  // → theme() stays 'dark', the user's 'light' choice is lost

If you use null for a value like theme that the user expects to persist across the whole app, you must set an explicit override (or none with null) for every route — don't leave routes out, or users will silently lose their preference the moment they visit an unlisted route after visiting a null one.

// ❌ Risky — only some routes have overrides, "gaps" can lose data unexpectedly
routeOverrides: {
    '/contact': { theme: null }
}

// ✅ Safe — every route is explicit, no route falls through unexpectedly
routeOverrides: {
    '/': { theme: 'dark' },
    '/about': { theme: 'dark' },
    '/contact': { theme: null },
    '/dashboard': { theme: 'dark' }
}

null is best reserved for values that are genuinely meant to be page-scoped and disposable (e.g. re-confirming currency at checkout) — not for app-wide preferences like theme or language that users expect to survive navigation everywhere.

Applying an override only once with __once

By default, an override in routeOverrides is reapplied every time you navigate to that route — even if the user manually changed the value while they were there. Add __once: true to an override so it only applies the first time the user visits that route, and is never reimposed again afterward:

const appStorage = createStorage({
    theme: 'dark' as 'dark' | 'light',
}, {
    prefix: 'app',
    routeOverrides: {
        '/dashboard': { theme: 'light', __once: true }
    }
});

// First visit — the override applies
appStorage.setRoute('/dashboard');
appStorage.theme(); // → 'light'

// User changes it manually
appStorage.theme.set('dark');

// Leaves and comes back to /dashboard
appStorage.setRoute('/dashboard');
appStorage.theme(); // → 'dark' — NOT reset to 'light', the override already "used up" its one application

__once doesn't restrict the user from changing the value afterward — it only stops the automatic override from reimposing itself. The user (or your app) can keep calling .set() freely, forever, exactly like any other value.

The "already applied" state is stored in localStorage (under a prefix__route-once__ key), so it survives full page reloads — it's not just an in-memory flag that resets when the user refreshes the browser. Calling destroy() on the whole StorageResult also clears this registry for that prefix, so any __once overrides can be applied again from scratch — this is expected, since destroy() is meant to fully reset the storage as if it had never been used.

Without __once → the override always wins on every visit to that route
With __once     → the override only wins the first time ever;
                   after that, the value behaves like a normal signal

Use __once when you want a route to have a sensible starting value the first time a user lands there, but want to fully respect whatever they choose afterward — for example, defaulting /dashboard to 'light' on first visit, without overriding a returning user's later choice of 'dark' or any other value.

⚠️ Important — routeOverrides (with or without __once) is always ONE shared value, never per-route isolation

This is the single most common point of confusion, so it deserves its own callout: routeOverrides always operates on the same key (e.g. app:theme), regardless of which route you're on. Whether or not you use __once, there is still only one theme value shared across your entire app — routeOverrides only controls when that one shared value gets overwritten automatically, never whether different routes get their own independent copies.

If what you actually want is for /home and /about to each keep their own, fully independent value — where changing one never affects the other, no matter what — routeOverrides is the wrong tool entirely, even with __once. Use a separate prefix per page instead:

// Home page — its own isolated storage
const homeStorage = createStorage({ theme: 'dark' as 'dark' | 'light' }, { prefix: 'home' });

// About page — a completely separate isolated storage
const aboutStorage = createStorage({ theme: 'light' as 'dark' | 'light' }, { prefix: 'about' });

// Changing homeStorage.theme never affects aboutStorage.theme, and vice versa —
// they are two entirely different localStorage keys ('home:theme' and 'about:theme')

Choosing the right pattern

You want...Use
Each page to have its own value, totally independent, foreverSeparate prefix per page
One shared app-wide value that gets reset to a specific value every time a route is visitedrouteOverrides without __once
One shared app-wide value with a sensible starting suggestion per route, but the user's later choice (from any route) is respected everywhere afterwardrouteOverrides with __once: true
A value to disappear entirely while on a specific route (e.g. force re-confirmation)routeOverrides with null for that route (see the destructive-null warning above)
prefix per page:
  home:theme = 'dark'    ← completely separate key
  about:theme = 'light'  ← completely separate key
  Changing one never touches the other. True isolation.

routeOverrides without __once:
  app:theme = ??? (one key, shared)
  Every visit to '/' forces 'dark'. Every visit to '/about' forces 'light'.
  Feels "independent" because it resets on every visit, but it's the
  SAME key being overwritten each time — a manual change is lost
  the moment you revisit an overridden route.

routeOverrides with __once:
  app:theme = ??? (one key, shared)
  '/' suggests 'dark' the first time ever, '/about' suggests 'light'
  the first time ever. After that, whatever the user sets — from
  ANY route — persists everywhere, permanently.

Connecting setRoute() to your router

typed-storage doesn't know what a "route" is — you tell it, by calling setRoute() whenever navigation happens. This is a couple of lines of glue code specific to whichever router you use:

// Vue Router
router.afterEach((to) => appStorage.setRoute(to.path));

// SvelteKit
import { page } from '$app/stores';
page.subscribe((p) => appStorage.setRoute(p.route.id ?? ''));

// Astro (mostly server-rendered, no SPA navigation to track)
appStorage.setRoute(Astro.url.pathname);

// Vanilla JS / no framework
window.addEventListener('popstate', () => {
    appStorage.setRoute(window.location.pathname);
});

For Angular and React, @jeanharo98/typed-storage-angular and @jeanharo98/typed-storage-react wire this up for you automatically — see their documentation for details.


✅ Options validation

createStorage() validates option combinations upfront and throws a clear error instead of failing silently or behaving unexpectedly:

createStorage({ token: '' }, { encrypt: true });
// ❌ Throws: typed-storage: opciones inválidas:
//    - encrypt está activado pero falta "secret"

createStorage({ theme: 'dark' }, { version: 2 });
// ❌ Throws: typed-storage: opciones inválidas:
//    - version está definida pero falta "migrations"

createStorage({ token: '' }, { ttl: -100 });
// ❌ Throws: typed-storage: opciones inválidas:
//    - ttl no puede ser negativo

createStorage({ theme: 'dark' }, { quotaThreshold: 150 });
// ❌ Throws: typed-storage: opciones inválidas:
//    - quotaThreshold debe estar entre 0 y 100

This catches common misconfigurations at the moment createStorage() is called, rather than leaving you to debug why encryption or migrations "aren't working" later.


✅ Runtime validation with Zod (optional)

createStorage()'s schema-type registry (documented above) only checks the primitive JavaScript type — string, number, boolean, object. It doesn't enforce actual business rules like "this must be a valid email" or "this number must be between 0 and 120". For that, typed-storage supports optional runtime validation using Zod (or any validator with a compatible safeParse method — Zod isn't a hard dependency).

import { createStorage } from 'typed-storage';
import { z } from 'zod';

const appStorage = createStorage({
    email: '',
    age: 0
}, {
    validate: {
        email: z.string().email(),
        age: z.number().min(0).max(120)
    }
});

appStorage.email.set('jean@gmail.com'); // ✅ valid, saved normally
appStorage.email.set('not-an-email');
// ❌ Throws: typed-storage: valor inválido para "email": Invalid email

appStorage.age.set(-5);
// ❌ Throws: typed-storage: valor inválido para "age": ...

Clean error messages, even with multiple validation issues

When the validator's error object has a Zod-style issues array (as safeParse produces), typed-storage extracts and joins the individual issue messages instead of stringifying the whole error object — so you get a readable message instead of a JSON blob of {code, path, message, ...} for every failed rule:

const appStorage = createStorage({
    note: { title: '' }
}, {
    validate: {
        note: z.object({
            title: z.string().min(1, 'Title cannot be empty')
        })
    }
});

appStorage.note.set({ title: '' });
// ❌ Throws: typed-storage: valor inválido para "note": Title cannot be empty
//    (not a raw JSON array of Zod issues)

ValidationError — structured access to validation failures

.set() throws a ValidationError (not a plain Error) when validation fails, exposing the field name and clean message as separate properties — so you don't have to parse the full error string to show a clean message in your UI:

import { ValidationError } from 'typed-storage';

try {
    appStorage.note.set({ title: '' });
} catch (err) {
    if (err instanceof ValidationError) {
        console.log(err.field);        // 'note'
        console.log(err.cleanMessage); // 'Title cannot be empty' — ready to show to the user
        console.log(err.message);      // full context, useful for logs/debugging:
                                        // 'typed-storage: valor inválido para "note": Title cannot be empty'
    }
}
// Real example — showing a clean message in an Angular/React form:
try {
    draftStorage.currentNote.set(updatedNote);
    errorMessage.set('');
} catch (err) {
    if (err instanceof ValidationError) {
        errorMessage.set(err.cleanMessage); // just "Title cannot be empty", not the full string
    }
}

Zod is optional, not a hard dependency

typed-storage doesn't import Zod internally — it only expects the object passed to validate[key] to have a safeParse(value) method that returns { success: boolean, error?: any }, which is exactly Zod's schema interface. This means:

# Install Zod yourself, only if you want to use validate
pnpm add zod

If you never use validate, typed-storage has zero extra bundle weight from this feature — nothing is imported unless you provide validators.

Only keys with a validate entry are checked

createStorage({
    email: '',
    theme: 'dark' as 'dark' | 'light'  // no validator provided for this key
}, {
    validate: {
        email: z.string().email()
        // theme is not listed — no validation runs for it, .set() always succeeds
    }
});

🔌 Plugin system

Extend typed-storage without forking it. A plugin is a plain object with optional hook functions that fire at key moments:

import { createStorage, Plugin } from 'typed-storage';

const loggerPlugin: Plugin = {
    onCreate: (schema, options) => {
        console.log('[Plugin] storage created with schema:', schema);
    },
    onSet: (key, newValue, oldValue) => {
        console.log(`[Plugin] "${key}" changed:`, oldValue, '→', newValue);
    },
    onReset: (key) => {
        console.log(`[Plugin] "${key}" was reset`);
    },
    onRemove: (key) => {
        console.log(`[Plugin] "${key}" was removed`);
    }
};

const appStorage = createStorage({
    theme: 'dark' as 'dark' | 'light'
}, {
    plugins: [loggerPlugin]
});

appStorage.theme.set('light'); // → "[Plugin] "theme" changed: dark → light"

Available hooks

HookCalled whenSignature
onCreateOnce, when createStorage() runs(schema, options?) => void
onSetEvery .set() call(key, newValue, oldValue) => void
onResetEvery .reset() call(key) => void
onRemoveEvery .remove() call(key) => void

All hooks are optional — implement only what your plugin needs. Multiple plugins can be registered at once via the plugins array, and they're called in the order they're listed.

What plugins are good for

✅ Logging value changes to an external service (Sentry, LogRocket)
✅ Analytics on which keys change most often
✅ Custom audit trails (similar in spirit to typed-storage-devtools'
   change history, but programmatic and app-specific)
✅ Triggering side effects when specific keys change
   (e.g. syncing to a backend, updating document.title, etc.)

Plugins have zero overhead when not used — options?.plugins?.forEach(...) is skipped entirely if plugins is undefined.


⚔️ Conflict resolution for cross-tab sync

With sync: true, if two tabs change the same key at nearly the same moment, the last one to write "wins" by default — this is fine most of the time, but it means the earlier change is silently lost with no way to detect it happened. conflictResolution: 'timestamp' prevents this by ignoring incoming cross-tab changes that are older than what you already have locally:

const appStorage = createStorage({
    cart: []
}, {
    sync: true,
    conflictResolution: 'timestamp' // default: 'last-write-wins' (no comparison)
});
Tab A: cart.set([...productX])  ← at 10:00:00.100
Tab B: cart.set([...productY])  ← at 10:00:00.150

Without conflictResolution ('last-write-wins', the default):
  → Whichever StorageEvent arrives last wins, regardless of timing
  → Tab A's change can silently overwrite Tab B's more recent one
    if its StorageEvent happens to be processed later

With conflictResolution: 'timestamp':
  → Every .set() records an updatedAt timestamp
  → When a cross-tab change arrives, it's only applied if its
    updatedAt is newer than the current local value's updatedAt
  → An older change arriving late is ignored instead of overwriting
    a newer one

How it works

Every value saved by .set() includes an internal updatedAt: Date.now() timestamp. When sync: true and conflictResolution: 'timestamp' are both set, the cross-tab listener compares the incoming change's timestamp against the current local value's timestamp before applying it — older changes are dropped silently, newer ones are applied normally.

conflictResolution is only relevant together with sync: true —
it has no effect if you're not syncing across tabs, since there's
nothing to compare against.

📦 Archiving data to IndexedDB with archive() and restore()

Unlike the automatic quota-exceeded fallback (which only kicks in when localStorage is full), archive() and restore() let you intentionally move data between localStorage and IndexedDB — freeing up real space while a page or feature isn't in active use, and bringing it back when needed.

const pageStorage = createStorage({
    formDraft: { title: '', content: '', tags: [] }
}, { prefix: 'blog-editor' });

// While the user is actively editing, data lives in localStorage (fast, sync)
pageStorage.formDraft.set({ title: 'My post', content: '...', tags: ['tech'] });

// When the user leaves the page (e.g. in a router navigation hook,
// or a component's cleanup/unmount):
await pageStorage.archive();
// → moves the data to IndexedDB, then removes it from localStorage,
//   actually freeing that space (unlike moving between localStorage keys)

// When the user comes back to the page:
await pageStorage.restore();
// → reads from IndexedDB, writes it back into localStorage via .set()
//   (so the signal updates correctly), then clears the IndexedDB copy

Why this is different from the quota-exceeded fallback

Quota-exceeded fallback (automatic):
  → Only triggers when localStorage.setItem() actually fails
  → Reactive — you don't control when it happens

archive() / restore() (manual):
  → You decide exactly when to archive — e.g. leaving a specific page
  → Useful even when localStorage isn't full — the goal is keeping
    localStorage lean while data isn't actively being used

Both methods are async

Since IndexedDB is inherently asynchronous, archive() and restore() return Promises — they don't block your synchronous .set()/() calls, but you should await them (or handle them as Promises) when you need to know they've completed:

// Angular — archive on component destroy
ngOnDestroy(): void {
    this.storageService.storage.archive();
}

// React — archive on unmount
useEffect(() => {
    return () => {
        storage.archive();
    };
}, []);

archive() removes the data from localStorage synchronously, before doing anything with IndexedDB — so a component that mounts immediately after (e.g. navigating straight to a new note) never reads stale data left behind by a slow IndexedDB write. Only the IndexedDB write itself happens asynchronously afterward.

⚠️ Don't combine archive()-on-leave with sync: true on the same storage

archive() calls .remove() on each key, which — with sync: true — dispatches a real cross-tab storage event. If another tab is actively editing the same data (e.g. two tabs open on the same draft, relying on conflictResolution: 'timestamp'), that tab will see the data disappear the moment the first tab archives it, even though the second tab is still using it. These two features solve different problems and don't compose safely on the same storage:

sync + conflictResolution  → assumes multiple tabs may be actively 
                              using the same data at the same time
archive()-on-leave          → assumes you're the only one using the 
                              data and it's safe to move away

If you need both patterns in the same app, use them on DIFFERENT
keys/storages rather than the same one. For small values (like a
single note's draft), archiving usually isn't worth it anyway —
archive() is meant for genuinely large data; reach for it only
when the data doesn't also need live multi-tab collaboration.

🔁 Automatic fallback to IndexedDB on quota exceeded

If localStorage.setItem() fails specifically because the browser's storage quota was exceeded (QuotaExceededError), typed-storage automatically backs up that value to IndexedDB instead of losing it or throwing an uncaught error:

const appStorage = createStorage({
    cart: []
});

appStorage.cart.set([...hugeArrayOfProducts]);
// If localStorage is full:
// → a warning is logged
// → the value is saved to IndexedDB in the background (fire-and-forget)
// → your app doesn't crash

// On a later page load, if localStorage still has nothing for that key,
// typed-storage automatically checks IndexedDB and restores the value:
const cart = appStorage.cart(); // starts as initialValue, then updates
                                  // moments later once IndexedDB responds

Why this doesn't break the synchronous API

createStorage() is synchronous by design — .set() and reading a signal never return Promises. IndexedDB is asynchronous by nature, so this fallback is implemented as "fire-and-forget in the background":

1. .set() always returns immediately, synchronously, as always
2. If localStorage.setItem() throws QuotaExceededError specifically:
   → a background async call saves the value to IndexedDB
   → your code doesn't wait for it, doesn't need to await anything
3. On the next createStorage() call for that key (e.g. after a page reload):
   → if localStorage has nothing, an async check against IndexedDB runs
   → if something is found, the signal updates via onChange a moment later

This means the restored value can appear a few milliseconds after the page loads, rather than being available instantly — a reasonable trade-off for not losing the data at all when localStorage is full. Only QuotaExceededError triggers this fallback; other errors from setItem() are re-thrown normally, since they usually indicate a real bug rather than a storage limit.


📊 Quota monitoring

localStorage has a limit of roughly 5-10MB per domain (it varies by browser). If you exceed it, setItem() throws a QuotaExceededError — and if unhandled, your app can silently break. onQuotaWarning lets you get notified before that happens, so you can act (clean up old data, move things to createHeavyStorage, warn the user, etc.):

const appStorage = createStorage({
    cart: []
}, {
    prefix: 'shop',
    onQuotaWarning: (percentUsed) => {
        console.warn(`⚠️ localStorage is at ${percentUsed}% of its estimated capacity`);
    },
    quotaThreshold: 80 // optional, defaults to 80 (%)
});

onQuotaWarning is checked after every .set() call. The percentage is an estimate — there's no native browser API to query exact remaining quota, so typed-storage sums the character length of every key and value currently in that storage (local or session) and compares it against a conservative assumed limit of 5MB.

Without onQuotaWarning → no overhead, nothing is calculated
With onQuotaWarning     → recalculates total usage on every .set() call
                          (negligible cost for typical apps with a
                          reasonable number of keys)

🧠 Memory fallback — complete protection

If localStorage/sessionStorage is unavailable (Safari private browsing with strict settings, storage disabled by policy, etc.), typed-storage transparently falls back to an in-memory implementation — at every level, not just when reading/writing individual keys. This includes internal operations like registering the schema, applying migrations, the __once registry cleanup on destroy(), and archive()/restore(). Your app keeps working (values just won't persist across reloads) instead of crashing with an uncaught storage error.

The in-memory fallback is a single shared instance per storage type (local/session) — every signal and every internal operation reads and writes the same in-memory data, so things like archive() correctly find data written by individual signals, and setRoute()'s __once tracking stays consistent across your whole app, even when running entirely without real browser storage.

🔄 Schema Migrations

When your schema changes between versions, migrations ensure users don't lose their data.

// Version 1 — what users had stored:
// localStorage['app:theme'] = '"dark"'
// localStorage['app:fontSize'] = '16'

// Version 2 — your new schema:
const appStorage = createStorage({
    theme: 'dark' as 'dark' | 'light',
    preferences: {          // ← new nested object
        fontSize: 16,
        language: 'es'
    }
}, {
    prefix: 'app',
    version: 2,             // ← current schema version
    migrations: {
        1: (oldData) => ({  // ← transforms v1 data to v2
            theme: oldData.theme,
            preferences: {
                fontSize: oldData.fontSize,  // moves fontSize inside preferences
                language: 'es'              // adds new field with default
            }
        })
    }
});

// Old data is automatically migrated on first load
console.log(appStorage.preferences()); // { fontSize: 16, language: 'es' }

Chained migrations (v1 → v2 → v3)

createStorage(schema, {
    prefix: 'app',
    version: 3,
    migrations: {
        1: (data) => ({         // v1 → v2
            theme: data.theme,
            preferences: {
                fontSize: data.fontSize,
                language: 'es'
            }
        }),
        2: (data) => ({         // v2 → v3
            ...data,
            preferences: {
                ...data.preferences,
                sidebarOpen: true  // adds new field in v3
            }
        })
    }
});

How migrations work

1. On createStorage(), reads the saved version from localStorage
   key: 'prefix__version__'

2. If no version saved → new install, saves current version and continues

3. If saved version < current version:
   → reads all current data from localStorage, correctly decrypting
     and decompressing each value first if those options were used,
     and unwrapping the internal {value, expiresAt, updatedAt} format
     so your migration functions receive plain values (e.g. oldData.theme
     is 'dark', not an internal wrapper object)
   → matches keys precisely (using 'prefix:' as a boundary, not just
     any key that happens to start with the same characters — e.g.
     prefix 'app' never matches an unrelated key like 'approved:x')
     and always excludes typed-storage's own internal registry keys
   → applies each migration in order (v1→v2, v2→v3, etc.)
   → saves migrated data back to localStorage, re-applying compress/encrypt
     if those options are set, so migrated data stays protected
   → updates the version key

4. If saved version === current version → nothing to do

🔍 Schema type registry

createStorage() automatically registers the primitive type of each schema key in a special localStorage entry:

createStorage({
    theme: 'dark' as 'dark' | 'light',
    sidebarOpen: true,
    fontSize: 16
}, { prefix: 'app' });

// Automatically stored in localStorage as:
// localStorage['__typed-storage-schema__'] = {
//     "app": { "theme": "string", "sidebarOpen": "boolean", "fontSize": "number" }
// }

You don't need to do anything for this — it happens automatically from the schema you already define. This registry exists so tools like typed-storage-devtools can validate edits before they're saved, preventing things like setting sidebarOpen to the string "trues" when it should be a boolean.

Note: this captures the primitive type (string, number, boolean, object) via typeof, not the specific literal union ('dark' | 'light') — that information doesn't exist in compiled JavaScript, only at TypeScript compile time.

🗄️ Heavy data with IndexedDB

For large datasets that exceed localStorage's ~5MB limit (file lists, extensive history, large collections), use createHeavyStorage — a separate async API backed by IndexedDB.

import { createHeavyStorage } from 'typed-storage';

const heavyStorage = createHeavyStorage({
    documents: [] as Document[],
    userPhotos: [] as Photo[]
}, {
    dbName: 'myapp-storage',
    ttl: 86400000  // optional — same TTL support as the sync API
});

// All operations are async — IndexedDB is asynchronous by nature
await heavyStorage.documents.set([...manyDocuments]);
const docs = await heavyStorage.documents.get();
await heavyStorage.documents.remove();

heavyStorage.documents.onChange((newValue) => {
    console.log('documents changed:', newValue);
});

await heavyStorage.clear();

Why a separate API?

createStorage() uses a synchronous Signal-like API by design — that's the core value of typed-storage. IndexedDB is asynchronous by nature, so mixing it into the same API would break that synchronous contract.

createStorage()       → sync, Signal-like, for UI preferences and small state
createHeavyStorage()  → async, Promise-based, for large datasets

If you only need small values (theme, language, settings), stick with createStorage(). Use createHeavyStorage() only when you specifically need to store data beyond localStorage's size limits.

HeavySignal<T> API

MemberDescription
signal.get()Returns a Promise with the current value
signal.set(value)Stores the value, returns a Promise
signal.remove()Deletes the value, returns a Promise
signal.onChange(cb)Subscribes to value changes (called synchronously after set/remove). Returns an unsubscribe function

⚙️ Options

const appStorage = createStorage(schema, options);
OptionTypeDefaultDescription
prefixstringPrepends prefix: to every key in localStorage
storage'local' | 'session''local'Use sessionStorage instead of localStorage
ttlnumberTime to live in milliseconds — key expires after this time. Must be >= 0
syncbooleanfalseSync values across browser tabs via StorageEvent
versionnumberCurrent schema version — requires migrations if set
migrationsRecord<number, (data) => data>Migration functions per version — required if version is set
compressbooleanfalseCompresses data with LZ-string before storing
encryptbooleanfalseObfuscates data with XOR + Base64 — requires secret, see security note below
secretstringRequired when encrypt: true — the obfuscation key
routeOverridesRecord<string, Record<string, any> & { __once?: boolean }>Maps routes to key values, applied via setRoute(). Use null to remove a key for a route, __once: true to apply an override only on the first visit
validateRecord<string, { safeParse(value): { success, error? } }>Optional runtime validation per key, compatible with Zod schemas
onQuotaWarning(percentUsed: number) => voidCalled after .set() if estimated storage usage exceeds quotaThreshold
quotaThresholdnumber80Percentage (0-100) at which onQuotaWarning fires
conflictResolution'last-write-wins' | 'timestamp''last-write-wins'With sync: true, 'timestamp' ignores incoming cross-tab changes older than the current local value
pluginsPlugin[]Array of plugins with onCreate/onSet/onReset/onRemove hooks

Invalid combinations (encrypt without secret, version without migrations, negative ttl) throw a descriptive error immediately when createStorage() is called.


📦 Compression

For large or repetitive data (lists, history, complex objects), enable compression to reduce the space used in localStorage:

const appStorage = createStorage({
    cart: { items: [] }
}, {
    prefix: 'shop',
    compress: true
});

appStorage.cart.set({ items: [...manyProducts] });
// Data is compressed with LZ-string before saving
// and decompressed automatically when read

When to use it

✅ Useful for:
   - Large lists (shopping carts, history)
   - Repetitive JSON structures
   - Data approaching localStorage's ~5MB limit

❌ Not needed for:
   - Small values like theme, language, fontSize
   - The compression overhead isn't worth it for tiny data

Compression only runs when compress: true is explicitly set — there's zero overhead for the default use case.

Using compress together with encrypt

Both options can be combined safely — compress is applied first, then encrypt, and the reverse order happens automatically when reading. Internally, compression uses a Base64-safe variant (compressToBase64/decompressFromBase64) specifically so the result remains valid input for the XOR + Base64 obfuscation step:

const secureStorage = createStorage({
    formDraft: { title: '', content: '' }
}, {
    compress: true,
    encrypt: true,
    secret: 'your-secret-key'
});

🔔 onChange

Subscribe to changes on any key. onChange() returns an unsubscribe function:

const unsubscribe = appStorage.theme.onChange((newValue) => {
  console.log('theme changed to:', newValue);
  document.body.setAttribute('data-theme', newValue);
});

appStorage.theme.set('light'); // → 'theme changed to: light'
appStorage.theme.reset();      // → 'theme changed to: dark'
appStorage.theme.remove();     // → 'theme changed to: dark' (initialValue)

// When you no longer need the subscription:
unsubscribe();
appStorage.theme.set('purple'); // → callback no longer fires

This matters for components or code that subscribes and unsubscribes repeatedly over an app's lifetime (e.g. a modal that mounts and unmounts many times) — without calling the returned unsubscribe function, old callbacks would keep accumulating and firing indefinitely, even after the code that registered them is no longer relevant.


🔒 Encryption (XOR obfuscation)

typed-storage can obfuscate values using XOR + Base64 before storing them. This is not real cryptography — read this section carefully before using it.

const secureStorage = createStorage({
    token: ''
}, {
    encrypt: true,
    secret: 'your-secret-key',  // required when encrypt is true
    ttl: 3600000                // recommended — expire alongside your real token
});

secureStorage.token.set('eyJhbGciOiJIUzI1NiJ9.xxx.yyy');
// Stored in localStorage as obfuscated text, not the readable JWT

const token = secureStorage.token();
// Automatically decrypted — returns the real JWT

⚠️ What this actually protects against

✅ Hides the value from casual inspection in DevTools/Application/Storage
✅ Discourages non-technical users from reading or copying the value
✅ Combined with ttl, expires alongside your backend token

❌ Does NOT protect against a technical attacker
❌ Does NOT protect against debugger breakpoints — the secret and
   decrypted value are visible in memory while the app runs
❌ Is NOT equivalent to real cryptography (AES, etc.)

Why this limitation exists — and why no frontend library can fix it

The secret you pass lives in your JavaScript code, which runs in the user's browser. No matter the algorithm used (XOR, AES, anything), the key must be present in the frontend to decrypt the value, which means it's always inspectable:

1. The secret travels safely over HTTPS — that's not the problem
2. Once it reaches the browser, it must be used by your JS to decrypt
3. Anyone with DevTools open can set a breakpoint where decryption
   happens and read the secret and the decrypted value directly
4. This is true even with industry-standard encryption (Web Crypto AES) —
   the algorithm's strength doesn't matter if the key is exposed

This is a fundamental limitation of any frontend-only encryption — not a flaw specific to typed-storage's XOR implementation.

Full Unicode support

encrypt correctly handles any Unicode content — accented characters, non-Latin scripts, emojis — by encoding to UTF-8 bytes before applying XOR and Base64, rather than operating on raw UTF-16 code units. This avoids btoa() errors that a naive byte-unsafe XOR implementation would otherwise throw for any non-ASCII content:

const secureStorage = createStorage({
    nombre: ''
}, { encrypt: true, secret: 'your-secret-key' });

secureStorage.nombre.set('José García 日本語 🎉');
secureStorage.nombre(); // → 'José García 日本語 🎉' — round-trips correctly

For real security with auth tokens

// In your backend (Express / NestJS):
res.cookie('authToken', token, {
    httpOnly: true,   // JavaScript cannot read this — ever
    secure: true,     // HTTPS only
    sameSite: 'strict'
});

httpOnly cookies are the only approach where the token never becomes accessible to JavaScript running in the browser — because the browser itself enforces the restriction, not your code.

When encrypt is still worth using

✅ You understand it's obfuscation, not security
✅ You want to deter casual inspection, not block determined attackers
✅ You're combining it with ttl so values expire predictably
✅ The data isn't critical enough to justify httpOnly cookie infrastructure
   (e.g. you're prototyping, or it's a low-stakes internal tool)

❌ Don't rely on this alone for banking, healthcare, or any data where
   a breach has real consequences — use httpOnly cookies on the backend

🅰️ Usage with Angular

pnpm add @jeanharo98/typed-storage @jeanharo98/typed-storage-angular

See @jeanharo98/typed-storage-angular for full documentation.

@Service()
export class StorageService {
    storage: AppStorage;

    constructor() {
        const ts = new TypedStorageService();
        this.storage = ts.initialize({
            theme: 'dark' as 'dark' | 'light',
            language: 'es' as 'es' | 'en',
        }, { prefix: 'app', sync: true }) as unknown as AppStorage;
    }
}

⚛️ Usage with React

pnpm add @jeanharo98/typed-storage @jeanharo98/typed-storage-react

See @jeanharo98/typed-storage-react for full documentation.

function App() {
    const storage = useStorage({
        theme: 'dark' as 'dark' | 'light',
        language: 'es' as 'es' | 'en',
    }, { prefix: 'app', sync: true });

    return <p>Theme: {storage.theme}</p>;
}

📋 API Reference

createStorage(schema, options?)

Creates a storage object from a schema. Returns a StorageResult<T> with one StorageSignal per key, plus clear(), destroy(), batch(), and setRoute().

computed(signals, compute)

Combines one or more StorageSignals into a derived reactive value. Returns a function that recomputes on every call.

ValidationError

Thrown by .set() when validate is configured and the value fails validation. Extends Error.

MemberDescription
messageFull context string, e.g. typed-storage: valor inválido para "note": Title cannot be empty
fieldThe schema key that failed validation
cleanMessageJust the validator's message, ready to display in a UI without parsing

StorageSignal<T>

MemberDescription
signal()Returns the current value
signal.set(value)Updates the value and persists to storage
signal.reset()Resets to initialValue and persists (key still exists)
signal.remove()Removes the key from storage and resets in memory
signal.has()Returns true if the key exists in storage
signal.onChange(cb)Subscribes to value changes. Returns an unsubscribe function

StorageResult<T>

MemberDescription
[key]One StorageSignal per schema key
clear()Calls reset() on all keys in the schema (keys still exist)
destroy()Calls remove() on all keys — completely removes them from storage
batch(values)Updates multiple keys in a single call
setRoute(route)Applies the routeOverrides entry matching route, if any
archive()Moves all schema keys to IndexedDB and removes them from localStorage. Async
restore()Brings back archived keys from IndexedDB into localStorage, updating signals. Async

Plugin

Interface for extending typed-storage behavior. All hooks are optional.

HookSignature
onCreate?(schema: StorageSchema, options?: StorageSignalOptions) => void
onSet?(key: string, newValue: any, oldValue: any) => void
onReset?(key: string) => void
onRemove?(key: string) => void

PackageDescription
@jeanharo98/typed-storage-angularAngular wrapper with native Signals
@jeanharo98/typed-storage-reactReact wrapper with useStorage() hook
typed-storage-devtoolsChrome DevTools extension for real-time inspection

📄 License

MIT