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,
  options?: GetOptions<T> & { detailed?: false },
): T | null;

appStorage.getItem<T>(
  key: StorageKey,
  options: GetOptions<T> & { detailed: true },
): StorageReadResult<T>;

appStorage.getItem<T>(
  key: StorageKey,
  options: GetOptions<T>,
): T | null | StorageReadResult<T>; // Dynamic boolean.

By default, returns null if the key is missing, expired, or fails the supplied schema validation. If the entry is expired, getItem() removes it from storage. Detailed mode returns the outcome described below.

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
detailed boolean Return a marked read outcome instead of a value or null. Defaults to false.
migrateFrom MigrateFromOptions 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 migrateFrom: { key, deserialize }, a physically missing destination triggers an import from an exact key in the same backend. The synchronous deserializer must return:

interface MigrateFromOptions {
  key: string;
  deserialize: (raw: string) => { value: unknown; options?: StorageOptions };
}

value is validated with the read schema and the transformed output is persisted. Optional options reuse the ordinary write expiration rules. Use expiresAt to preserve an original deadline; ttl starts a fresh lifetime at import time. Expired source values are skipped. Occupied destinations always win, including unreadable or expired bytes. Importing reads verify the written destination and retain the source after success or failure. The app owns verification and eventual cleanup.

This replaces the old legacy option and LegacyOptions type. Old calls throw, and every non-wrapper return is invalid, including strings. See importing existing values for breaking-change examples, errors, quota recovery, and React usage.

Detailed reads

Pass { detailed: true } to getItem() to distinguish stored nullish values from unsuccessful reads. The ordinary call still returns a value or null. Both modes accept schema validation and migrateFrom.

const result = appStorage.getItem<string | null>('theme', { detailed: true });

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);
}

Every detailed outcome contains __result: true. This reserved field marks the wrapper; value holds the original payload, including objects with their own status, value, or marker fields. Ordinary reads never inspect or unwrap result-like payloads. The marker is a convention and can collide with arbitrary user data; the requested mode determines the return type.

Status Additional fields Meaning
success value: T, optional migrated: true A decoded, validated value, including null or undefined.
missing None No destination or importable source exists.
expired expiresAt: number The deadline passed; expired destinations are removed, expired sources are retained.
unsupported None Foreign data or an unsupported/invalid envelope.
parse-error error: unknown Configured parsing or source deserialization failed.
validation-error issues: readonly StandardSchemaV1.Issue[] The schema returned issues.

migrated: true appears only after an importing read writes and verifies the destination. Reading an occupied destination omits it, including if a user callback populated the destination before import. This confirms the copied representation and expiration; app-level verification and source cleanup remain the caller’s responsibility. The source is always retained.

StorageReadResult<T> and GetOptions<T> are exported from both main and core entry points. TypeScript infers the result from a literal detailed: true; false or omitted returns T | null. A dynamic boolean produces a union of those types.

Both modes remove expired destinations when Date.now() > expiresAt and emit expire events; at the exact timestamp the entry remains valid. Other unsuccessful outcomes retain stored bytes. Parsing follows the instance’s primary serializer and explicit fallback policy. Backend access, write and cleanup failures, thrown schema errors, malformed migration contracts, and unsupported asynchronous callbacks still throw. Detailed mode does not catch operational exceptions.

Breaking change from getItemResult

getItemResult() and its ReadOptions type were removed. Replace calls with getItem(key, { ...options, detailed: true }), and use GetOptions for read options. The result now includes the reserved marker on every outcome and the optional migration flag on success. There is no separate migration method or result API.

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?