Skip to content

Typed Storage

Fully typed, async, key-prefixed storage over any backend.

@nice-code/util is the quiet foundation the rest of the stack stands on: one typed storage interface over any backend (browser, Durable Object, memory), plus the WebCrypto that authenticates every secure connection. You rarely import it directly — but a realm’s identity persists through it, and a secure handshake is built on it. This page is the storage half; Crypto is the other.

Terminal window
bun add @nice-code/util

ITypedStorage<T> is a typed key/value store you can put on top of any backend. Every key and value is typed, every method is async, and all your keys get a shared prefix so they don’t collide with anything else.

import { createTypedWebLocalStorage } from "@nice-code/util";
interface IAppStorage {
user_id: string;
theme: "light" | "dark";
recent_searches: string[];
}
const storage = createTypedWebLocalStorage<IAppStorage>({
localStorage,
keyPrefix: "app:",
});
// All keys autocomplete; values are typed
await storage.setJson("theme", "dark");
const theme = await storage.getJson("theme"); // "light" | "dark" | undefined
const userId = await storage.getJsonOrDef("user_id", "guest"); // string
// Read-modify-write in one call
await storage.updateJsonWithDef("recent_searches", [], (cur) => [...cur, "query"]);
await storage.removeItem("theme");
await storage.clearAll(); // removes only keys this storage has written

Without schemas, typed storage trusts the cast: getJson returns whatever was stored, typed as T[K]. That’s fine for values only your own code writes — but durable storage is where stale shapes live longest. A value written by version N and read by version N+3 has silently crossed three schema evolutions with zero runtime checking; the type lie only surfaces wherever the value finally misbehaves.

Pass a schemas map (any Standard Schema library — Valibot, Zod, …) and the storage boundary gets the same treatment the action layer gives the wire — validated on the way out and in, fail-closed:

import { createDurableObjectTypedStorage, StorageValidationError } from "@nice-code/util";
import * as v from "valibot";
// A persisted discriminated union: exact keys, and an unknown mode FAILS CLOSED — it can never
// load as legacy. v.strictObject + v.variant is the pattern for versioned durable state.
const vBridgeState = v.variant("mode", [
v.strictObject({ mode: v.literal("single_action"), actionId: v.string() }),
v.strictObject({ mode: v.literal("multi_action_v1"), turnSeq: v.number() }),
]);
interface IBridgeStorage {
state: v.InferOutput<typeof vBridgeState>;
}
const storage = createDurableObjectTypedStorage<IBridgeStorage>({
durableObjectStorage: ctx.storage,
schemas: { state: vBridgeState },
onInvalid: (error) => report(error), // observation only — the operation still throws
});

With a schema present on a key:

  • Reads (getJson) validate the stored value; defaults (getJsonOrDef, updateJsonWithDef) validate whichever value is actually used, so a bad fallback never escapes.
  • Writes (setJson) validate before the adapter sees the value — a bad write is caught at the writer, not the next reader.
  • Updates validate in both directions: the updater never receives invalid stored data, and its result validates before persisting.
  • The schema’s output (result.value) is what is returned and persisted, so transforms/defaults can never make storage and readers disagree.
  • Failures throw StorageValidationError (carrying key + issues); onInvalid observes but never suppresses. Validation is synchronous-only — an async schema fails validation. Keys without a schema keep the blind-cast behavior, and JSON parse errors stay their own class.
import {
createTypedWebLocalStorage,
createTypedWebSessionStorage,
createDurableObjectTypedStorage,
createTypedMemoryStorage_string,
createTypedMemoryStorage_json,
} from "@nice-code/util";
// Browser
const local = createTypedWebLocalStorage<IAppStorage>({ localStorage, keyPrefix: "app:" });
const session = createTypedWebSessionStorage<IAppStorage>({ sessionStorage });
// Cloudflare Durable Objects (inside a DO class)
const doStorage = createDurableObjectTypedStorage<IDOStorage>({
durableObjectStorage: ctx.storage,
keyPrefix: "do:",
});
// In-memory (testing / SSR) — string-serialized or JSON-native
const mem = createTypedMemoryStorage_string<IAppStorage>();
const memJson = createTypedMemoryStorage_json<IAppStorage>();
// Share state between instances by passing the same Map
const shared = new Map<string, string>();
const a = createTypedMemoryStorage_string<IAppStorage>({ memoryStorageMap: shared });
const b = createTypedMemoryStorage_string<IAppStorage>({ memoryStorageMap: shared });
interface ITypedStorage<T extends Record<string, any>> {
getJson<K>(key: K): Promise<T[K] | undefined>;
getJsonOrDef<K>(key: K, defVal: T[K]): Promise<T[K]>;
setJson<K>(key: K, val: T[K]): Promise<void>;
updateJson<K>(key: K, updater: (cur: T[K] | undefined) => T[K]): Promise<void>;
updateJsonWithDef<K>(key: K, defVal: T[K], updater: (cur: T[K]) => T[K]): Promise<void>;
removeItem<K>(key: K): Promise<void>;
clearAll(): Promise<void>;
}

Fill in the methods interface to put typed storage on top of any backend (Redis, KV, …):

import {
createTypedStorage,
EStorageAdapterType,
StorageAdapter,
type IStorageAdapterMethods_String,
} from "@nice-code/util";
const redisMethods: IStorageAdapterMethods_String = {
type: EStorageAdapterType.string,
getItem: async (key) => redis.get(key),
setItem: async (key, value) => { await redis.set(key, value); },
removeItem: async (key) => { await redis.del(key); },
};
const storage = createTypedStorage<IMySchema>({
storageAdapter: new StorageAdapter({ methods: redisMethods, keyPrefix: "app:" }),
});

You can also use the lower-level StorageAdapter directly (with untyped keys), including createJsonGetterSetter<T>(key) for one key and withKeyPrefix(prefix) for a child namespace. A child inherits its parent’s trackKeysForClearing policy: deriving from an intentionally untracked adapter never silently creates a __usedKeys__ index.