greatstorage
v0.5.0
Published
Gives localStorage superpowers. Handles serialization of rich types, key expiration, namespacing, and schema validation
Maintainers
Readme
greatstorage
Gives localStorage superpowers. Handles serialization of rich types, key expiration, namespacing, and schema validation — so you don't have to.
Features
- Store anything: Stores
Set,Map,Date,RegExp,BigInt, circular references, and more using devalue - TTL / expiration: Set a
ttlin milliseconds or an absoluteexpiresAttimestamp. Expired items are treated as missing, can be removed ongetItem(), and can be swept withclearExpired() - Namespacing: Isolate keys with a configurable
prefixandseparator - Schema validation: Validate retrieved values against any Standard Schema with synchronous validation (Zod, Valibot, ArkType, etc.)
- Use any
Storagebackend: Works withlocalStorage,sessionStorage, or anyStorage-compatible implementation. An in-memory storage implementation is provided as well - ESM and CJS: Tree-shakeable dual builds with full TypeScript types
Installation
npm install greatstorageHow it works
Internally, every value is stored as an object rather than being written to the storage raw. That object carries the user value, an optional expiry, a version, and an internal __gs marker. The marker is there so greatstorage can reliably tell its own entries apart from unrelated keys already living in the same Storage. The version is there so the on-disk format can evolve later without guessing which shape an older entry used if this library ever needs to change the internal storage structure.
Refer to the longer section below for more explanation regarding internals and design decisions.
Usage
Basic
Store and retrieve objects without the JSON.stringify dance. You're welcome.
// lib/storage.ts
import { createStorage } from 'greatstorage';
// Create an app-wide singleton instance.
export const storage = createStorage();// app.ts
import { storage } from './lib/storage';
storage.setItem('user', { name: 'Alice', age: 30 });
storage.getItem('user'); // { name: 'Alice', age: 30 }Store anything
Yes, localStorage can finally handle a Set, or any data type you throw at it. It only took the entire JavaScript ecosystem to get here.
Uses devalue by default, but you can bring your own serializer.
storage.setItem('tags', new Set(['a', 'b', 'c']));
storage.getItem('tags'); // Set {'a', 'b', 'c'}
storage.setItem('metadata', new Map([['key', 'value']]));
storage.getItem('metadata'); // Map {'key' => 'value'}
storage.setItem('date', new Date('2025-01-01'));
storage.getItem('date'); // Date 2025-01-01T00:00:00.000ZTTL / expiration
Store data temporarily. Like Snapchat, but for your storage keys.
Note: Expired data behaves like a missing key. getItem() removes expired entries on read, while has(), key(), and length simply ignore them. Use clearExpired() to proactively sweep them.
// Expires in 60 seconds
storage.setItem('token', 'abc123', { ttl: 60_000 });
// Expires at a specific date
storage.setItem('session', { id: 1 }, { expiresAt: new Date('2025-12-31') });
// Expired items return null
storage.getItem('token'); // null (after 60s)Namespacing
Because your keys deserve their own personal space, away from whatever chaos other libraries left behind.
const appStorage = createStorage({ prefix: 'my-vibe-coded-app' });
appStorage.setItem('theme', 'dark'); // stored as "my-vibe-coded-app:theme"
appStorage.getItem('theme'); // 'dark'
localStorage.getItem('theme'); // null
// clear() only removes keys within the namespace
appStorage.clear();Check, remove, and clear
The usual housekeeping. Someone has to take out the trash.
storage.has('user'); // true
storage.removeItem('user');
storage.has('user'); // false
storage.clear(); // remove all entries written by `greatstorage`
storage.clearExpired(); // remove only expired entriesType-safe access
TypeScript can't read localStorage at compile time (yet), but you can at least pretend your data is typed.
interface User {
name: string;
age: number;
}
const user = storage.getItem<User>('user');
// user is typed as User | null
storage.getOrInit<User>('user', () => ({ name: 'Alice', age: 30 }));
storage.updateItem<User>('user', (current) => ({
...current!,
age: current!.age + 1,
}));However, the true safe way is to validate with a schema during read.
Schema validation
Trust no one — especially since browser storage is open to tampering by users. Validate with any libraries that support Standard Schema, as long as validation is synchronous.
import { z } from 'zod';
const UserSchema = z.object({ name: z.string(), age: z.number() });
// Returns typed value if valid, null if validation fails
const user = storage.getItem('user', { schema: UserSchema });Not just localStorage
Pass any Storage-compatible backend. Use sessionStorage for tab-scoped data, or createMemoryStorage() for tests and server-side rendering.
import { createStorage, createMemoryStorage } from 'greatstorage';
const storage = createStorage({
storage: typeof window === 'undefined' ? createMemoryStorage() : undefined,
});Custom serializer
Don't like devalue? Bring your own stringify/parse and we won't judge. Much.
import superjson from 'superjson';
const storage = createStorage({
serializer: { stringify: superjson.stringify, parse: superjson.parse },
});If you provide your own serializer and want to keep devalue out of your bundle entirely, import from greatstorage/core instead. The only difference is that serializer is required.
import { createStorage } from 'greatstorage/core';
import superjson from 'superjson';
const storage = createStorage({
serializer: { stringify: superjson.stringify, parse: superjson.parse },
});Additional APIs
Because getItem and setItem weren't enough, here are some bonus methods you didn't know you needed.
storage.getOrInit()
Get the value if it exists, or writes to storage if it doesn't. Either way, you're getting something back.
const prefs = storage.getOrInit('prefs', () => ({
theme: 'light',
lang: 'en',
}));storage.updateItem()
Read-modify-write in one call. Three separate statements was apparently too much work even when AI is writing all the code.
storage.updateItem('count', (current) => (current ?? 0) + 1);API
createStorage(options?: CreateStorageOptions): GreatStorage
Creates a new storage instance. All options are optional.
| Option | Type | Default | Description |
| ------------ | ------------ | -------------- | ------------------------------------------------------ |
| prefix | string | — | Key prefix for namespacing |
| separator | string | ":" | Separator between prefix and key |
| storage | Storage | localStorage | Underlying Storage backend |
| serializer | Serializer | devalue | Custom serializer with stringify and parse methods |
Also available from greatstorage/core where serializer is required and devalue is not bundled. See Custom serializer.
Returns a GreatStorage instance, that has the same interface as Storage, with additional APIs.
GreatStorage instance
A GreatStorage instance with the following methods:
storage.getItem<T = unknown>(key: string): T | null
Retrieves and deserializes a value. Returns null if the key is missing or expired. If the entry is expired, getItem() removes it from storage.
storage.getItem<T>(key: string, options: { schema: StandardSchema }): T | null
Retrieves and deserializes a value. Returns null if the key is missing, expired, or fails schema validation.
If the entry is expired, getItem() removes it from storage.
Options:
| Option | Type | Description |
| -------- | ---------------- | --------------------------------------------------------------- |
| schema | StandardSchema | Validate the value during read. Async schemas are not supported |
storage.setItem<T = unknown>(key: string, value: T, options?: StorageOptions): void
Serializes and stores a value. Options:
| Option | Type | Description |
| ----------- | ---------------- | ---------------------------- |
| ttl | number | Time-to-live in milliseconds |
| expiresAt | Date \| number | Absolute expiration time |
ttl and expiresAt cannot be used together.
storage.getOrInit<T>(key: string, factory: () => T, options?: StorageOptions): T
Returns the existing value for key, or calls factory() to create, store, and return a new value.
Options:
| Option | Type | Description |
| ----------- | ---------------- | ---------------------------- |
| ttl | number | Time-to-live in milliseconds |
| expiresAt | Date \| number | Absolute expiration time |
ttl and expiresAt cannot be used together.
getOrInit() is useful for migrating from an existing localStorage (but non-greatstorage) key. To do that, specify a factory function that reads from the existing localStorage key.
const theme = storage.getOrInit('theme', () => localStorage.getItem('theme'));storage.updateItem<T = unknown>(key: string, updater: (value: T | null) => T, options?: StorageOptions): T
Calls updater(currentValue) where currentValue is the existing value (or null), stores the result, and returns it.
Options:
| Option | Type | Description |
| ----------- | ---------------- | ---------------------------- |
| ttl | number | Time-to-live in milliseconds |
| expiresAt | Date \| number | Absolute expiration time |
ttl and expiresAt cannot be used together.
storage.removeItem(key: string): void
Removes a single key from the current namespace.
storage.has(key: string): boolean
Returns true if the key exists and is not expired. Expired entries are treated as missing and are not removed by has().
storage.key(index: number): string | null
Returns the key at the given zero-based index among non-expired entries, or null if the index is out of bounds. Expired entries are skipped but not removed.
storage.clear(): void
Removes all entries written by greatstorage in the current namespace.
Entries outside the namespace and values not written by greatstorage are left untouched.
storage.clearExpired(): void
Removes only expired entries in the current namespace.
storage.length: number
The number of non-expired entries in the current namespace.
Expired entries are excluded from the count but not removed unless read via getItem() or swept with clearExpired().
createMemoryStorage(): Storage
Returns an in-memory Storage implementation. Useful for tests or server-side usage.
How it works (longer version)
greatstorage is intentionally small. Under the hood, it's a thin wrapper around a Storage instance (e.g. localStorage / sessionStorage) that serializes your value together with a bit of metadata, then gives you a nicer API for reading it back safely.
Internal entry format
Each stored value is wrapped in an internal envelope before being written to storage. That envelope includes a marker, a format version, your actual value, and an optional expiry timestamp.
This is what lets greatstorage tell its own entries apart from random keys already sitting in localStorage, attach TTL metadata without changing your value shape, and leave room to evolve the format later without pretending raw strings are part of the contract.
Serialization by default, not by accident
By default, greatstorage uses devalue instead of JSON.stringify(). The point is not novelty. It's that JSON quietly loses or mangles useful JavaScript values like Set, Map, Date, RegExp, BigInt, undefined, NaN, and circular references.
The serializer is configurable on purpose. If you want superjson, or plain JSON for a more constrained setup, you can swap in your own stringify and parse methods and keep the rest of the API unchanged. If you bring your own serializer, import from greatstorage/core to keep devalue out of your bundle entirely.
Expiration is lazy on read
TTL support is implemented as metadata on the entry, not as a background cleanup job. When you call getItem(), expired entries are treated as missing and removed immediately. has(), key(), and length also treat expired entries as missing, but they do not mutate storage.
That split is deliberate. Reads that already need the value can pay the cleanup cost, while bookkeeping-style operations stay predictable and side-effect free. If you want to proactively sweep old entries, clearExpired() is the explicit escape hatch.
Namespaces stay in their lane
When you pass a prefix, greatstorage stores keys as prefix + separator + key. That isolates one logical namespace from another without requiring a separate storage backend.
It also means clear() only removes greatstorage entries in the current namespace. Keys written by other code, or values that were never written by greatstorage in the first place, are ignored rather than parsed opportunistically and guessed at.
Why a factory, not a class
createStorage() returns a plain object built from a closure instead of an instance of a class. That keeps helper functions and configuration genuinely private, avoids this binding nonsense when methods are destructured, and makes the result easy to mock in tests.
It also matches the library's actual shape better: you're configuring a storage adapter around any Storage-compatible backend, whether that's localStorage, sessionStorage, or the in-memory implementation.
Why validation happens on read
Schema validation happens when values come back out of storage, not when they go in. That's the trust boundary that matters. Browser storage is user-tamperable, and even valid data at write time can become invalid later if your schema changes.
So getItem({ schema }) validates the retrieved value right before your app uses it. If validation fails, you get null. If the schema is async, greatstorage rejects it immediately rather than hiding asynchronous behavior behind a synchronous storage API.
Caveats
- Still synchronous storage: This wraps
localStorage-style APIs, so reads and writes are still synchronous and still subject to browser storage quotas. - Changing serializers can strand old entries:
getItem()has a JSON fallback, but enumeration-based APIs likeclear(),clearExpired(),key(), andlengthdepend on the current serializer being able to parse old values. If you ever need to change serializers, we recommend changing theprefixand using a new namespace. - Expired entries are cleaned up lazily: Expired data is hidden from reads immediately, but it may still occupy storage until
getItem()touches it orclearExpired()is called. nullmeans several things:getItem()returnsnullfor missing keys, expired entries, foreign values not written bygreatstorage, parse failures, and schema validation failures.- Stored
nulland missing keys look the same: If that distinction matters, pairgetItem()withhas(). - Existing raw
localStoragevalues are invisible: This library only reads entries with its internal marker, so migrating older plain-string or plain-JSON data requires explicit migration code. You can usegetOrInit()for this purpose, specifying afactoryfunction that reads from the existinglocalStoragekey. - No atomic updates across tabs or callers:
getOrInit()andupdateItem()are read-modify-write helpers, not transactional operations. - Schema validation is sync-only and non-destructive: Async schemas throw, and invalid stored values return
nullwithout being removed automatically.
See also
- store2 by Nathan Bubna: feature-rich
localStoragewrapper with namespacing and plugins - store.js by Marcus Westin: cross-browser
localStoragewrapper with fallback plugins - unstorage by UnJS: universal key-value storage with pluggable drivers (memory, filesystem, Redis, etc.)
- storage-box by Shahrad Elahi: simple
localStoragewrapper with TTL support - lscache by Pamela Fox:
localStoragewrapper with memcached-inspired expiration
License
MIT
