createStorage
createStorage<T extends object = Record<string, unknown>>(options?: StorageOptions): StorageInstance<T>A typed, observable key-value store.
interface StorageOptions {
provider?: StorageProvider;
serializer?: StorageSerializer;
prefix?: string;
}Usage
import { createStorage, memoryStorageProvider } from '@studiometa/js-toolkit';
interface Prefs {
theme: 'light' | 'dark';
seen: string[];
}
const prefs = createStorage<Prefs>({
provider: memoryStorageProvider,
prefix: 'app:',
});
prefs.set('theme', 'dark');
prefs.get('theme'); // 'light' | 'dark' | undefined
prefs.get('theme', 'light'); // 'light' | 'dark'
prefs.has('seen');
prefs.keys();
prefs.delete('seen');
prefs.clear();
prefs.destroy();The instance
interface StorageInstance<T extends object = Record<string, unknown>> {
get<K extends keyof T>(key: K): T[K] | undefined;
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K];
set<K extends keyof T>(key: K, value: T[K]): void;
delete<K extends keyof T>(key: K): void;
has<K extends keyof T>(key: K): boolean;
keys(): (keyof T)[];
clear(): void;
subscribe<K extends keyof T>(
key: K,
callback: (value: T[K] | undefined) => void,
options?: { immediate?: boolean },
): () => void;
destroy(): void;
}The two get overloads are the whole difference between "it may not be there" and "here is what to use if it is not".
Observing a key
const unsubscribe = prefs.subscribe(
'theme',
(value) => {
document.documentElement.dataset.theme = value ?? 'light';
},
{ immediate: true },
);A Signal is created per key, on the first subscription, so a store nobody observes holds nothing.
In a component, hand the unsubscribe back from mounted():
mounted() {
return prefs.subscribe('theme', (value) => this.apply(value));
}destroy()
Releases every subscription and the shared sync listeners at once. A store that outlives the page needs no call; a store scoped to a component does.
prefix
Namespaces every key in the underlying provider, so two stores can share one backing area without colliding. The prefix is the store's business — a provider never sees an unprefixed key and never adds one.
serializer
interface StorageSerializer {
serialize(value: unknown): string;
deserialize(value: string): unknown;
}Defaults to jsonSerializer. Failures are reported, not thrown:
storage.serialize-failed— nothing is written;storage.deserialize-failed— the default is returned.
Syncing with the outside
syncEvents on the provider is a list of window event names and nothing more. createStorage() subscribes one shared, reference-counted listener per name while at least one key is observed, and re-reads every observed key when it fires.
The event carries no usable state, so the subscriber re-reads rather than trusting a payload.
A known gap
A provider whose changes arrive on a BroadcastChannel or through an observer has no way to announce them yet.
In Node
One storage instance runs in Node over the memory provider, which is what test/package-node-consumer.js exercises. Nothing in the store touches window unless a provider's syncEvents asks for it.