Skip to content
ultrastorageultrastorage
Esc
↑↓navigate↵open⌘Jpreview
On this page

Storage API

Factory options, typed reads, writes, expiration, subscriptions, and key enumeration.

createStorage()

Creates a new storage instance.

createStorage(options?: CreateStorageOptions): UltraStorage;

All options are optional.

Option Type Default Description
prefix string None Key prefix for namespacing
separator string ":" Advanced override for the separator between prefix and key
storage Storage localStorage Where values are saved
serializer Serializer devalue Custom serializer with stringify and parse methods
fallbackParser Serializer['parse'] Disabled Synchronous parser tried only after the configured parser throws
onQuotaExceeded 'clear-expired' Disabled Clear expired entries in this namespace and retry a failed quota write once

Keep the default : separator unless you need to match an existing stored-key convention; see custom separators. Changing prefix or separator after writing data requires explicit migration to keep existing entries accessible through the new configuration.

Also available from ultrastorage/core where serializer is required and devalue is not bundled. See Custom serializer.

Returns an UltraStorage instance. ultrastorage provides familiar Storage methods, with typed reads, rich-value serialization, expiration, namespacing, and subscriptions.

getItem() returns deserialized values rather than always returning strings. Named property access such as storage.theme is unsupported; use getItem('theme') and setItem('theme', value). Existing raw storage values require explicit migration, and clear() only removes recognized entries within the configured namespace.

Create and reuse a namespaced instance for application state:

import { createStorage } from 'ultrastorage';

export const appStorage = createStorage({ prefix: 'acme' });

Storage instance

The examples below use the appStorage instance created above. Its methods follow the familiar Storage API while accepting rich JavaScript values:

import { appStorage } from './lib/app-storage';

appStorage.setItem('theme', 'dark');
appStorage.getItem('theme'); // 'dark'

The UltraStorage instance exposes the following methods and properties. Signatures below use appStorage as the instance name.

StorageKey

Key-taking methods use the exported StorageKey type:

type StorageKey = string | readonly string[];

String keys are stored unchanged relative to the namespace. Array keys are serialized from their ordered string segments into an opaque canonical string without using separator.

Use a string for a single identifier or an array to preserve structural segments:

const userId = '42';

appStorage.setItem('theme', 'dark');
appStorage.setItem(['users', userId], {
  name: 'Alice',
  role: 'admin',
});

appStorage.getItem(['users', userId]);
// { name: 'Alice', role: 'admin' }

Reserved array-key encoding

Avoid authoring string keys beginning with \u0000us:a:, the reserved prefix used to encode array keys. A string that exactly matches a canonical array encoding addresses the same entry:

appStorage.setItem(['users', '42'], 'Alice');
appStorage.getItem('\u0000us:a:["users","42"]'); // 'Alice'

key() and keys() return such a key as an array even if it was originally passed as an encoded string. Other string keys remain strings. Use the original array when addressing an array key; subscription events also supply a reusable encoded string through change.key.

getItem()

Retrieves and deserializes a stored value.

appStorage.getItem<T = unknown>(key: StorageKey): T | null;

appStorage.getItem<T>(
  key: StorageKey,
  options: { schema?: StandardSchema; legacy?: LegacyOptions },
): T | null;

Returns null if the key is missing, expired, or fails the supplied schema validation. If the entry is expired, getItem() removes it from storage.

Foreign values, unrecognized envelopes, and entries that cannot be parsed also return null. Backend access failures, failed expiration cleanup, and thrown schema errors propagate; see error handling.

Options:

Option Type Description
schema StandardSchema Validate the value during read. Async schemas are not supported
legacy LegacyOptions Import a legacy key when the destination is physically missing; see importing existing values

Use a schema to validate the persisted value and infer its type:

import { z } from 'zod';

const ThemeSchema = z.enum(['light', 'dark']);
const theme = appStorage.getItem('theme', { schema: ThemeSchema });
// 'light' | 'dark' | null

With legacy: { key, deserialize }, a missing destination triggers a one-time import from an exact key in the same backend. The synchronous decoder receives the raw string. A successful import stores the schema output (or decoded value) without expiration, removes the source, and notifies subscribers. This opted-in read can write and throw on write or cleanup failures. See importing existing values for examples and failure behavior.

getItemResult()

Reads a value with a distinct outcome for each reason it could not be returned. getItem() keeps its existing value-or-null behavior.

appStorage.getItemResult<T = unknown>(key: StorageKey, options?: ReadOptions<T>): StorageReadResult<T>;

ReadOptions accepts a Standard Schema via schema, with the same synchronous validation and output inference as getItem(). Legacy import is available only through getItem(). Both types are exported from ultrastorage and ultrastorage/core.

status Additional fields Meaning
success value: T Read succeeded, including stored null or undefined.
missing None No entry exists at the key.
expired expiresAt: number The deadline passed; the entry was removed.
unsupported None Parsed data is foreign, has an unsupported version, or has an invalid envelope.
parse-error error: unknown The configured parser threw and no fallback was supplied, or both parsers threw; error is the primary parser’s thrown value.
validation-error issues: readonly StandardSchemaV1.Issue[] The schema returned validation issues.
const result = appStorage.getItemResult<string | null>('theme');

if (result.status === 'success') {
  console.log(result.value); // A stored null is a successful read.
} else if (result.status === 'missing') {
  appStorage.setItem('theme', 'light');
} else if (result.status === 'validation-error') {
  console.error(result.issues);
}

Like getItem(), this method removes an expired entry when Date.now() > expiresAt and emits an expire notification to subscribers. At the exact deadline the entry is still readable. A subsequent read after cleanup returns missing. Expiration is checked before schema validation. Other unsuccessful outcomes preserve the stored bytes.

The configured serializer is tried first. An explicit fallbackParser runs only if that parser throws synchronously; no fallback runs by default. Use fallbackParser: JSON.parse to retain compatibility with older JSON envelopes. A parser returning an invalid envelope produces unsupported without trying another parser. Fallback reads do not rewrite stored bytes. Valid current __us and legacy __gs envelopes are both accepted. Backend access and removal failures, exceptions thrown by schemas, and unsupported asynchronous validation still throw. Either parser returning a promise or thenable throws a synchronous TypeError, without trying another parser or returning unsupported or parse-error. These failures are not converted into read statuses.

setItem()

Serializes and stores a value.

appStorage.setItem<T = unknown>(
  key: StorageKey,
  value: T,
  options?: StorageOptions,
): void;

Options:

Option Type Description
ttl number Finite time-to-live in milliseconds
expiresAt Date | number | null Absolute deadline; null removes expiration

ttl and expiresAt cannot be used together, including expiresAt: null. Omitting expiration options preserves the current unexpired deadline. Missing, expired, unreadable, and non-expiring entries are written without expiration. Pass expiresAt: null to explicitly remove expiration.

Store rich values and optionally expire them:

appStorage.setItem(
  'draft',
  {
    title: 'Introducing ultrastorage',
    updatedAt: new Date(),
  },
  { ttl: 86_400_000 },
);

getExpiration()

Inspect an entry’s expiration without reading its value or performing cleanup.

appStorage.getExpiration(key: StorageKey): StorageExpiration | null;

interface StorageExpiration {
  expiresAt: number | null;
}

Returns the stored deadline as a Unix timestamp in milliseconds, or { expiresAt: null } for a non-expiring entry. Missing and unreadable entries return null. A stored null value still has inspectable expiration.

Expired entries retain their inspectable deadline until they are physically removed, such as by getItem() or clearExpired(). Inspection does not remove data or notify subscribers. The result is a fresh snapshot. Backend read errors propagate.

A deadline has passed when Date.now() > expiresAt; subtract Date.now() from the timestamp to calculate remaining lifetime, which is negative after expiration.

setExpiration()

Change an existing entry’s expiration while preserving its value.

appStorage.setExpiration(key: StorageKey, options: ExpirationOptions | null): boolean;

type ExpirationOptions =
  | { ttl: number; expiresAt?: never }
  | { expiresAt: Date | number; ttl?: never };

ttl sets a lifetime from now; expiresAt sets an absolute deadline. Pass null to remove expiration. Returns true when applied to an existing, unexpired entry, including an unchanged deadline. Returns false for missing, expired, or unreadable entries without cleanup.

Options must specify exactly one finite expiration, or be null; invalid options throw TypeError, even for missing entries. Backend and serialization failures propagate. An unchanged deadline skips the write; changed serialized content emits set. Past deadlines remain lazy: the next getItem() removes the expired entry and emits expire. The operation rewrites the serialized envelope and is not atomic across tabs.

See expiration controls for examples.

removeItem()

Removes a single key from the current namespace.

appStorage.removeItem(key: StorageKey): void;
appStorage.removeItem('draft');
appStorage.getItem('draft'); // null

has()

Returns true if the key exists and is not expired.

appStorage.has(key: StorageKey): boolean;

Expired entries are treated as missing and are not removed by has().

This checks for a recognized, non-expired entry, including stored null. It does not apply schema validation, so it can return true while a validated read returns null.

if (!appStorage.has('preferences')) {
  appStorage.setItem('preferences', { theme: 'system' });
}

clear()

Removes all recognized entries in the current namespace, including expired entries.

appStorage.clear(): void;

Entries outside the namespace and values not written by ultrastorage are left untouched.

Corrupted envelopes, unsupported format versions, and entries unreadable by the configured parser and any explicit fallback are also skipped. They can remain in the backend and occupy space after clear() or clearExpired(). To discard a known unreadable entry, call removeItem(key); it does not require the value to be readable. During a format migration, use the old serializer to read entries you need to retain before removing them.

Scope follows string-prefix matching: overlapping prefixes can include each other’s data, and an instance without a prefix matches recognized entries across the backend. See namespace overlap.

appStorage.setItem('theme', 'dark');
localStorage.setItem('analytics-id', 'abc123');

appStorage.clear();

appStorage.getItem('theme'); // null
localStorage.getItem('analytics-id'); // 'abc123'

getOrInit()

Returns the existing value for key, or calls factory() to create, store, and return a new value.

appStorage.getOrInit<T>(
  key: StorageKey,
  factory: () => T,
  options?: StorageOptions,
): T;

Options:

Option Type Description
ttl number Finite time-to-live in milliseconds
expiresAt Date | number | null Absolute deadline; null removes expiration

ttl and expiresAt cannot be used together, including expiresAt: null. Omitting expiration options preserves the current unexpired deadline. Missing, expired, unreadable, and non-expiring entries are written without expiration. Pass expiresAt: null to explicitly remove expiration.

Initialize a value when an ordinary read returns null:

const preferences = appStorage.getOrInit('preferences', () => ({
  theme: 'system',
  locale: 'en',
}));

This includes missing, expired, unreadable, and stored-null values. A factory returning null runs again on the next call. Existing non-null values are returned without schema validation or changes to their expiration; options apply only when the factory result is written.

Factories must be synchronous, and initialization is not atomic across tabs or concurrent callers; see synchronous helpers.

getOrInit() is useful for migrating from an existing localStorage (but non-ultrastorage) key. To do that, specify a factory function that reads from the existing localStorage key.

const theme = appStorage.getOrInit('theme', () => localStorage.getItem('theme'));

updateItem()

Calls updater(currentValue) where currentValue is the existing value (or null), stores the result, and returns it.

appStorage.updateItem<T = unknown>(
  key: StorageKey,
  updater: (value: T | null) => T,
  options?: StorageOptions,
): T;

Options:

Option Type Description
ttl number Finite time-to-live in milliseconds
expiresAt Date | number | null Absolute deadline; null removes expiration

ttl and expiresAt cannot be used together, including expiresAt: null. Omitting expiration options preserves the current unexpired deadline. Missing, expired, unreadable, and non-expiring entries are written without expiration. Pass expiresAt: null to explicitly remove expiration.

const count = appStorage.updateItem<number>('cart-count', (current) => (current ?? 0) + 1);
// 1 when the key was previously missing

Omitting expiration options preserves the current unexpired deadline; passing a TTL starts a new lifetime from this write. Pass expiresAt: null to remove expiration. The updater must be synchronous and receives the value without schema validation. Updates are not atomic across tabs or concurrent callers; see synchronous helpers.

subscribe()

Observe one key and receive set, remove, or expire events.

appStorage.subscribe(
  key: StorageKey,
  listener: StorageListener,
  options?: SubscribeOptions,
): () => void;

SubscribeOptions is exported from the main and core entry points. Its optional reactiveExpiration: boolean defaults to false. When enabled, timer-driven expire events notify this subscription without deleting data; later cleanup may emit another expire event.

Returns an idempotent unsubscribe function that also cancels its timer. See the subscription guide for the event types, timing, errors, and cross-tab behavior.

Read the latest value when the subscribed key changes:

const unsubscribe = appStorage.subscribe('theme', (change) => {
  const theme = appStorage.getItem<'light' | 'dark'>('theme');
  console.log(change.type, theme);
});

appStorage.setItem('theme', 'dark'); // Logs: set dark
unsubscribe();

clearExpired()

Removes only expired entries in the current namespace.

appStorage.clearExpired(): void;
appStorage.setItem('search-results', ['first', 'second'], { ttl: 5 * 60_000 });

// Later, such as when the application starts again:
appStorage.clearExpired();

Only recognized entries can be checked for expiration; unreadable entries are skipped, as with clear().

length

The number of non-expired entries in the current namespace.

appStorage.length: number;

Expired entries are excluded from the count but not removed unless read via getItem() or swept with clearExpired().

Each access scans and decodes entries in the namespace. Use keys() to enumerate all keys in one scan instead of repeatedly reading length and calling key().

appStorage.setItem('theme', 'dark');
appStorage.setItem('locale', 'en');

console.log(appStorage.length); // 2

key()

Returns the key at the given zero-based index among non-expired entries, or null if the index is out of bounds.

appStorage.key(index: number): StorageKey | null;

Expired entries are skipped but not removed. Each call scans and decodes entries from the beginning until it reaches the requested index. Repeated calls to enumerate every key perform quadratic work; use keys() for full enumeration.

Keys are returned relative to the prefix. Array keys are restored as fresh arrays containing their original segments; string keys remain strings except for reserved array encodings. Every returned key can be passed directly to getItem(), removeItem(), and other key-taking methods. This return type differs from native Storage.key(), which returns only strings or null.

appStorage.setItem('theme', 'dark');
appStorage.setItem(['users', '42'], 'Alice');

appStorage.key(0); // 'theme'
appStorage.key(1); // ['users', '42']
appStorage.key(2); // null

keys()

Returns a fresh array of recognized, non-expired keys in the current namespace, in backend enumeration order.

appStorage.keys(): StorageKey[];

This method scans the backend once and decodes each matching entry once. Expiration is evaluated against the time when the call starts. Expired and foreign entries are skipped without removing anything or notifying subscribers.

Keys are returned relative to the prefix. Array keys are restored as fresh arrays containing their original segments; string keys remain strings except for reserved array encodings. Every returned key can be passed directly to getItem(), removeItem(), and other key-taking methods.

appStorage.setItem('theme', 'dark');
appStorage.setItem(['users', '42'], 'Alice');

appStorage.keys(); // ['theme', ['users', '42']]

The result is a snapshot: later writes or changes to the returned array, including nested key arrays, do not affect each other.

for (const key of appStorage.keys()) {
  console.log(key);
}

You can also safely remove entries while looping over the snapshot:

for (const key of appStorage.keys()) {
  appStorage.removeItem(key);
}

Error handling

Storage operations are synchronous and remain subject to backend access restrictions and storage quotas. By default, backend exceptions, serialization errors on writes, factory or updater exceptions, and thrown schema errors propagate to the caller. Async schemas and parsers throw a synchronous TypeError.

Even a read can fail: getItem() removes expired entries, so a backend removal error is thrown instead of returning null. In contrast, unrecognized data and schema issues return null, and synchronous parser errors follow the explicit fallback rules.

Handle failures where you call the API; ultrastorage does not silently substitute an in-memory backend. Bulk clearing can partially complete before a backend error, with no rollback. Completed removals still notify subscribers. Subscriber exceptions are handled separately and do not turn a completed write into a failure; see event delivery.

Recovering from quota failures

For caches with expiring data, opt in to cleanup and one retry:

import { createStorage } from 'ultrastorage';

export const cache = createStorage({
  prefix: 'cache',
  onQuotaExceeded: 'clear-expired',
});

try {
  cache.setItem('results', { items: ['first', 'second'] }, { ttl: 60_000 });
} catch (error) {
  // Cleanup or the retry can still fail. Handle the unsaved value in your app.
  console.error('Could not save cached results', error);
}

Only an error named QuotaExceededError from the backend’s setItem() triggers recovery. ultrastorage calls clearExpired() in the current namespace, then retries the same serialized write once, even if no expired entries were found. Serialization, factories, and updaters do not run again, and the write’s expiration deadline stays unchanged. This applies to setItem(), helper writes, setExpiration(), legacy imports, and React setters, including the core entry point.

Cleanup follows the usual namespace boundaries and skips live, foreign, corrupted, and unsupported entries. Without a prefix, recognized expired entries across the backend are eligible. Successful first writes do not trigger cleanup. There is no oldest-entry eviction or automatic memory fallback, and an object that is too large can still fail after cleanup.

If cleanup fails, its error propagates and no retry occurs. If the retry fails, its error propagates. Completed removals are not rolled back. Cleanup emits expire events, and a successful changed write emits set; listeners run after recovery finishes, including when it throws. No set event is emitted for a failed write.

createMemoryStorage()

Returns an in-memory Storage implementation.

createMemoryStorage(): Storage;

Useful for tests or server-side usage.

import { createMemoryStorage, createStorage } from 'ultrastorage';

const testStorage = createStorage({
  prefix: 'test',
  storage: createMemoryStorage(),
});

testStorage.setItem('theme', 'dark');
testStorage.getItem('theme'); // 'dark'

Was this page helpful?