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.
bun add @nice-code/utilITypedStorage
Section titled “ITypedStorage”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 typedawait storage.setJson("theme", "dark");const theme = await storage.getJson("theme"); // "light" | "dark" | undefinedconst userId = await storage.getJsonOrDef("user_id", "guest"); // string
// Read-modify-write in one callawait storage.updateJsonWithDef("recent_searches", [], (cur) => [...cur, "query"]);
await storage.removeItem("theme");await storage.clearAll(); // removes only keys this storage has writtenSchema-validated keys (opt-in)
Section titled “Schema-validated keys (opt-in)”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(carryingkey+issues);onInvalidobserves 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.
Adapters
Section titled “Adapters”import { createTypedWebLocalStorage, createTypedWebSessionStorage, createDurableObjectTypedStorage, createTypedMemoryStorage_string, createTypedMemoryStorage_json,} from "@nice-code/util";
// Browserconst 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-nativeconst mem = createTypedMemoryStorage_string<IAppStorage>();const memJson = createTypedMemoryStorage_json<IAppStorage>();
// Share state between instances by passing the same Mapconst shared = new Map<string, string>();const a = createTypedMemoryStorage_string<IAppStorage>({ memoryStorageMap: shared });const b = createTypedMemoryStorage_string<IAppStorage>({ memoryStorageMap: shared });The interface
Section titled “The interface”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>;}Wrapping your own backend
Section titled “Wrapping your own backend”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.