km-storage
v1.3.5
Published
Type-safe browser storage (localStorage / sessionStorage) with Zod schema validation and optional compression.
Maintainers
Readme
km-storage — Type-Safe localStorage & sessionStorage with Zod Validation
km-storage is a lightweight, fully typed browser storage library that wraps localStorage and sessionStorage with Zod schema validation. Every read and write is validated against your schema — no more silently broken data, no more manual JSON parsing, and full TypeScript intellisense on every key.
Table of Contents
- Why km-storage?
- Features
- Installation
- Quick Start
- Core Concepts
- API Reference — createStorage
- Options Reference
- zodCast — Standalone Zod Coercion Utility
- Serialization Utilities
- TypeScript Types
- Advanced Usage
- Export Styles
- Version Compatibility
- Browser Compatibility
- FAQ
- Contributing
- License
Why km-storage?
Most projects store data in localStorage with raw JSON.stringify / JSON.parse. This works until:
- A stored value silently becomes
nullor"undefined"after a refactor - You forget that
localStorageonly stores strings, and read a number back as a string - There is no warning when stored data no longer matches your current types
- You need reactive watchers for cross-tab state synchronization
- Stored entries pile up with no TTL mechanism
km-storage solves all of this with a single createStorage(schema) call:
import { createStorage } from 'km-storage';
import { z } from 'zod';
const store = createStorage(
z.object({
token: z.string(),
userId: z.number(),
darkMode: z.boolean().default(false),
}),
{ prefix: 'myapp' }
);
store.create('token', 'abc123'); // validated and stored
store.read('token'); // → 'abc123' (typed as string)
store.read('userId'); // → undefined (never written)
store.update('darkMode', (prev) => !prev); // functional update patternFeatures
- Zod schema validation — every
createandupdatevalidates the value before writing; everyreadvalidates and coerces the stored value back to the correct type - Full TypeScript intellisense — key names and value types are inferred directly from your Zod schema; wrong key names and wrong value types are compile errors
- TTL (time-to-live) — set a
ttlin milliseconds per entry; expired entries are auto-removed on the next read - Reactive
watch()— subscribe to changes on any key, including cross-tab changes via the nativestorageevent - Functional updater pattern —
update('count', prev => (prev ?? 0) + 1)— works exactly like React'suseStatesetter sessionStoragesupport — switch any store tosessionStoragewith one option- Optional compression — zipson-powered compression for large payloads, reducing storage footprint by 30–70%
- Prefix / namespace isolation — multiple independent stores coexist in the same tab without key collisions
- Standalone
zodCastutility — coerce any raw value (URL param, env var, form field) to a Zod-typed value; usable completely outside of km-storage - Zero lodash dependency — pure TypeScript with no heavyweight utility belt
- ESM + CJS + Universal builds — works with Vite, webpack, Rollup, Next.js, and any bundler
Installation
# npm
npm install km-storage zod
# yarn
yarn add km-storage zod
# pnpm
pnpm add km-storage zod
# bun
bun add km-storage zodNote:
zodis a required peer dependency (peerDependencies). km-storage requires Zod 4.x (>=4.0.0). Both packages must be installed in your project.
Quick Start
import { createStorage } from 'km-storage';
import { z } from 'zod';
// 1. Define your schema
const schema = z.object({
username: z.string(),
age: z.number(),
theme: z.enum(['light', 'dark']).default('light'),
rememberMe: z.boolean().default(false),
sessionToken: z.string().optional(),
});
// 2. Create a store — all keys are namespaced under 'app:'
const store = createStorage(schema, { prefix: 'app' });
// 3. Write (validated — throws ZodError on invalid data)
store.create('username', 'Alice');
store.create('age', 30);
store.create('theme', 'dark');
// 4. Read (fully typed return values)
store.read('username'); // → 'Alice' (string)
store.read('age'); // → 30 (number, not the string "30")
store.read('theme'); // → 'dark'
store.read('sessionToken'); // → undefined (was never written)
// 5. Update
store.update('age', 31); // plain value
store.update('age', (prev) => (prev ?? 0) + 1); // functional updater
// 6. Delete
store.remove('sessionToken');
store.removeAll(); // removes all app: keys
// 7. Watch for changes (same-tab and cross-tab)
const unsubscribe = store.watch('username', (current, previous) => {
console.log(`username changed from "${previous}" to "${current}"`);
});
store.create('username', 'Bob'); // → fires: "username changed from Alice to Bob"
unsubscribe(); // stop watching
// 8. Teardown (e.g. on component unmount)
store.destroy();Core Concepts
Schema-First Storage
Every createStorage call takes a z.object() as its first argument. This schema:
- defines the keys that can be stored (TypeScript prevents writing arbitrary keys not in the schema)
- defines the type of each value (TypeScript infers the exact return type of every
readcall) - validates values on write and coerces them back on read via
zodCast
Envelope Pattern
Internally, every stored value is wrapped in a typed envelope:
{ "v": <actualValue>, "e": <expiryTimestamp> }This means:
- Primitives (
number,boolean,null) survive the JSON round-trip without type coercion hacks — a stored42comes back as42, not"42" - TTL expiry timestamps travel with the value and need no separate storage key
- Future metadata can be added to the envelope without breaking existing entries
Prefix / Key Isolation
Every store is bound to a prefix. Keys in localStorage are stored as prefix:keyName. Two stores with different prefixes never interfere with each other.
app:username → '{"v":"Alice"}'
app:theme → '{"v":"dark"}'
admin:username → '{"v":"superadmin"}' ← separate store, no collisionAPI Reference — createStorage
function createStorage<SCHEMA extends z.ZodObject<any>, NAME extends string = string>(
schema: SCHEMA,
options?: StorageOptions<NAME>
): StorageInstance<z.infer<SCHEMA>>Creates and returns a storage instance bound to the given Zod schema and options.
| Parameter | Type | Description |
|-----------|------|-------------|
| schema | z.ZodObject<any> | A Zod object schema defining the keys and types for this store |
| options | StorageOptions | Optional configuration (see StorageOptions) |
Returns: a StorageInstance<z.infer<SCHEMA>> with the methods documented below.
create
store.create(name: K, value: Schema[K], options?: CreateOptions): voidValidates value against the field schema and writes it to storage. Throws a ZodError if the value is invalid. Replaces any existing entry for the same key.
store.create('username', 'Alice'); // ok
store.create('age', 30); // ok
store.create('age', 'thirty' as any); // throws ZodError — expected number
// With TTL: entry auto-expires after 60 seconds
store.create('sessionToken', 'tok_abc', { ttl: 60_000 });| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| name | keyof Schema | yes | Key name — must be a key in the Zod schema |
| value | Schema[K] | yes | Value to store — validated before writing |
| options.ttl | number | no | Time-to-live in milliseconds. Entry is auto-removed on the next read after expiry |
After
create, all registeredwatch()callbacks for that key fire synchronously with(newValue, previousValue).
read
store.read(name: K): Schema[K] | undefinedReads and returns the current value for name. Returns undefined if:
- the key was never written
- the stored envelope is malformed
- the entry's TTL has expired (the entry is auto-removed on this read)
The stored raw value is coerced back to the correct JavaScript type — a stored number is always returned as a number, not a string.
store.create('age', 30);
store.read('age'); // → 30 (number)
store.read('username'); // → undefined (never written)update
store.update(
name: K,
value: Schema[K] | ((current: Schema[K] | undefined) => Schema[K]),
options?: CreateOptions
): voidUpdates an existing entry. Accepts either a plain value or a functional updater — a function that receives the current value and returns the next value. This pattern is identical to React's useState setter.
The updated value is validated through the schema before writing. Throws ZodError on invalid data.
store.create('age', 10);
// Plain value
store.update('age', 20);
// Functional updater — receives current stored value
store.update('age', (prev) => (prev ?? 0) + 1); // → 21
// Works even when the key was never written (prev is undefined)
store.update('age', (prev) => prev ?? 0); // → 0 for a new keyupdate also accepts options.ttl to reset the expiry timer:
// Extend the session token TTL on every user action
store.update('sessionToken', (prev) => prev ?? 'tok_abc', { ttl: 30_000 });remove
store.remove(name: K): voidRemoves the entry for the given key. Safe to call even if the key was never written (no-op). After removal, all watch() callbacks for that key fire with (undefined, previousValue).
store.create('username', 'Alice');
store.remove('username');
store.read('username'); // → undefinedremoveAll
store.removeAll(): voidRemoves all entries belonging to this store (all keys prefixed with prefix:). Entries from other stores with a different prefix are not touched.
const store = createStorage(schema, { prefix: 'app' });
store.create('username', 'Alice');
store.create('age', 30);
store.removeAll();
store.read('username'); // → undefined
store.read('age'); // → undefinedreadAll
store.readAll(): Partial<Schema>Reads all keys in the schema and returns them as a partial object. Keys that were never written have value undefined.
store.create('username', 'Alice');
store.create('age', 30);
const data = store.readAll();
// → { username: 'Alice', age: 30, theme: undefined, rememberMe: undefined, sessionToken: undefined }Note: All schema keys appear in the returned object (including unwritten ones as
undefined). Usestore.read(key) !== undefinedto test if a specific key has been written.
watch
store.watch(
name: K,
callback: (current: Schema[K] | undefined, previous: Schema[K] | undefined) => void
): () => voidSubscribes to changes on a key. The callback fires:
- Same-tab changes: synchronously after every
create,update, orremovecall - Cross-tab changes: asynchronously via the browser's native
storageevent when another tab modifies the key
Returns an unsubscribe function — call it to stop watching.
const unsubscribe = store.watch('username', (current, previous) => {
console.log(`username: ${previous} → ${current}`);
});
store.create('username', 'Alice'); // fires: "username: undefined → Alice"
store.update('username', 'Bob'); // fires: "username: Alice → Bob"
store.remove('username'); // fires: "username: Bob → undefined"
unsubscribe(); // stop watching
store.create('username', 'Charlie'); // nothing firesMultiple watchers on the same key:
const unsub1 = store.watch('theme', (c) => console.log('watcher 1:', c));
const unsub2 = store.watch('theme', (c) => console.log('watcher 2:', c));
store.create('theme', 'dark');
// → "watcher 1: dark"
// → "watcher 2: dark"
unsub1(); // only watcher 1 stops
store.update('theme', 'light');
// → "watcher 2: light"Cross-tab watching:
The browser's storage event notifies other tabs — not the originating tab. km-storage handles this automatically: a single shared window.addEventListener('storage', ...) is attached lazily when the first watcher is registered and removed when the last watcher is unsubscribed.
// Tab A
const store = createStorage(schema, { prefix: 'shared' });
store.watch('username', (current) => {
console.log('Cross-tab update received:', current);
});
// Tab B — changes made here fire the callback in Tab A
const storeB = createStorage(schema, { prefix: 'shared' });
storeB.create('username', 'Alice');destroy
store.destroy(): voidRemoves all registered watchers and detaches the cross-tab storage event listener. Call this when the component or context owning the store is unmounted or disposed.
// React
useEffect(() => {
const unsub = store.watch('theme', handler);
return () => store.destroy(); // cleanup on unmount
}, []);Options Reference
StorageOptions
Passed as the second argument to createStorage.
type StorageOptions<NAME extends string = string> = {
prefix?: NAME;
mode?: StorageMode;
compress?: boolean;
};| Option | Type | Default | Description |
|--------|------|---------|-------------|
| prefix | string | 'km' | Namespace prefix prepended to every storage key: prefix:keyName. Use a unique prefix per store to avoid key collisions between stores. |
| mode | 'localStorage' \| 'sessionStorage' | 'localStorage' | Which Web Storage API to use. sessionStorage is cleared when the tab is closed. |
| compress | boolean | false | When true, values are compressed with zipson before storage. Useful for large objects. Do not change this flag after writing entries — reads will fail silently if the flag does not match the encoding used at write time. |
CreateOptions (TTL)
Passed as the optional third argument to create and update.
type CreateOptions = {
ttl?: number; // milliseconds
};| Option | Type | Description |
|--------|------|-------------|
| ttl | number | Time-to-live in milliseconds. The entry is automatically removed the next time it is read after the TTL has elapsed. Omitting ttl (or passing undefined) means the entry never expires. |
// Entry expires in 5 minutes
store.create('tempCode', 'XK-99', { ttl: 5 * 60 * 1000 });
// After 5 minutes:
store.read('tempCode'); // → undefined (expired entry auto-removed from storage)zodCast — Standalone Zod Coercion Utility
zodCast is a standalone utility exported from km-storage. It converts an unknown raw value — a string from a URL parameter, an environment variable, a form input, or any untyped source — into the correct JavaScript type described by a Zod schema.
zodCast has no dependency on km-storage internals and can be used independently in any project that works with Zod.
import { zodCast } from 'km-storage';
import { z } from 'zod';
zodCast(z.number(), '42') // → 42 (number)
zodCast(z.boolean(), 'true') // → true
zodCast(z.boolean(), 'false') // → false
zodCast(z.string(), 100) // → '100'
zodCast(z.number(), 'not-a-number') // → undefined
zodCast(z.object({ x: z.number() }), '{"x":1}') // → { x: 1 }
zodCast(z.array(z.number()), '[1,2,3]') // → [1, 2, 3]
zodCast(z.optional(z.string()), null) // → undefined
zodCast(z.nullable(z.string()), null) // → null
zodCast(z.string().default('fallback'), null) // → 'fallback'
zodCast(z.string().transform((s) => s.toUpperCase()), 'hello') // → 'HELLO'zodCast — Function Signature
function zodCast<T extends z.ZodTypeAny>(schema: T, raw: unknown): T['_zod']['output'] | undefinedReturns z.infer<T> if the value can be coerced to the schema's type, or undefined if coercion fails.
zodCast — Supported Schema Types
| Zod Schema | Raw input example | Cast result |
|------------|------------------|-------------|
| z.string() | 42, true, 'hello' | '42', 'true', 'hello' |
| z.number() | '3.14', '-7', 42 | 3.14, -7, 42 |
| z.boolean() | 'true', 'false', true | true, false, true |
| z.bigint() | '9007199254740993', 42n | 9007199254740993n, 42n |
| z.date() | '2024-01-15', 1705276800000 | Date, Date |
| z.null() | null, 'null' | null, null |
| z.undefined() | undefined | undefined |
| z.nan() | NaN, 'NaN' | NaN, NaN |
| z.literal(42) | '42', 42 | 42, 42 |
| z.enum(['a','b','c']) | 'a' | 'a' |
| z.nativeEnum(MyEnum) | 'UP', 0 | 'UP', 0 |
| z.object({ x: z.number() }) | '{"x":1}', { x: 1 } | { x: 1 } |
| z.array(z.number()) | '[1,2,3]', [1,2,3] | [1, 2, 3] |
| z.tuple([z.string(), z.number()]) | '["a",1]' | ['a', 1] |
| z.record(z.string(), z.number()) | '{"k":1}' | { k: 1 } |
| z.set(z.string()) | ['a','b'], '["a","b"]' | Set {'a','b'} |
| z.map(z.string(), z.number()) | [['a',1]] | Map { 'a' => 1 } |
| z.union([z.string(), z.number()]) | 42, 'hello' | 42, 'hello' |
| z.discriminatedUnion('type', […]) | { type: 'a', … } | matched variant |
| z.optional(z.string()) | null, undefined | undefined |
| z.nullable(z.string()) | null, 'null' | null |
| z.string().default('fallback') | null, undefined | 'fallback' |
| z.number().catch(0) | 'not-a-number' | 0 |
| z.string().transform(fn) | 'hello' | fn('hello') |
| z.string().pipe(z.coerce.number()) | '42' | 42 |
| z.any() / z.unknown() | any | parsed JSON if string, else as-is |
zodCast — Common Use Cases
import { zodCast } from 'km-storage';
import { z } from 'zod';
// URL search parameters
const page = zodCast(z.number().min(1).default(1), new URLSearchParams(location.search).get('page'));
// '3' → 3 | null → 1 (default)
// Environment variables
const isDebug = zodCast(z.boolean(), process.env.DEBUG);
// 'true' → true | undefined → undefined
// Form inputs
const choice = zodCast(z.enum(['a', 'b', 'c']), formData.get('choice'));
// 'b' → 'b' | 'invalid' → undefined
// API response fields that might be mistyped
const count = zodCast(z.number(), apiResponse.count);
// '42' → 42 | null → undefinedSerialization Utilities
km-storage exports its internal serialization helpers for low-level use cases.
encodeEntry
function encodeEntry(value: unknown, opts?: { ttl?: number; compress?: boolean }): stringWraps a value in an envelope and serializes it to a string suitable for storage.setItem().
import { encodeEntry } from 'km-storage';
encodeEntry(42);
// → '{"v":42}'
encodeEntry('hello', { ttl: 5000 });
// → '{"v":"hello","e":1718000000000}'
encodeEntry({ x: 1 }, { compress: true });
// → zipson-compressed stringdecodeEntry
function decodeEntry(raw: string, opts?: { compress?: boolean }): { value: unknown; expired: boolean } | nullDeserializes a raw storage string back to its envelope. Returns null if the string is not a valid envelope (e.g. plain text, legacy data).
import { decodeEntry } from 'km-storage';
decodeEntry('{"v":42}');
// → { value: 42, expired: false }
decodeEntry('{"v":"hello","e":1000}'); // timestamp in the past
// → { value: 'hello', expired: true }
decodeEntry('plain-string');
// → nullisPlainObject
function isPlainObject(value: unknown): value is Record<string, unknown>Returns true for plain {} objects. Returns false for arrays, class instances (Date, Map, Set), null, and primitives.
import { isPlainObject } from 'km-storage';
isPlainObject({}) // → true
isPlainObject({ a: 1 }) // → true
isPlainObject(Object.create(null)) // → true
isPlainObject([]) // → false
isPlainObject(new Date()) // → false
isPlainObject(null) // → false
isPlainObject(42) // → falseisJsonString
function isJsonString(value: unknown): booleanReturns true when value is a string whose top-level structure is a JSON object {} or array [].
import { isJsonString } from 'km-storage';
isJsonString('{"a":1}') // → true
isJsonString('[1,2,3]') // → true
isJsonString('hello') // → false
isJsonString('42') // → false (valid JSON but not object/array)
isJsonString('true') // → false
isJsonString(42) // → false (not a string)TypeScript Types
All important types are exported from km-storage and can be imported separately:
import type {
StorageMode,
StorageOptions,
CreateOptions,
WatchCallback,
Updater,
StorageInstance,
} from 'km-storage';StorageMode
type StorageMode = 'localStorage' | 'sessionStorage';StorageOptions
type StorageOptions<NAME extends string = string> = {
prefix?: NAME;
mode?: StorageMode;
compress?: boolean;
};CreateOptions
type CreateOptions = {
/** Time-to-live in milliseconds. Entry is auto-removed on next read after expiry. */
ttl?: number;
};WatchCallback
type WatchCallback<T> = (
current: T | undefined,
previous: T | undefined
) => void;Updater
/** A plain value or a function that derives the next value from the current one. */
type Updater<T> = T | ((current: T | undefined) => T);StorageInstance
type StorageInstance<S extends Record<string, unknown>> = {
create<K extends keyof S>(name: K, value: S[K], options?: CreateOptions): void;
read<K extends keyof S>(name: K): S[K] | undefined;
update<K extends keyof S>(name: K, value: Updater<S[K]>, options?: CreateOptions): void;
remove<K extends keyof S>(name: K): void;
removeAll(): void;
readAll(): Partial<S>;
watch<K extends keyof S>(name: K, callback: WatchCallback<S[K]>): () => void;
destroy(): void;
};S is the inferred output shape of your Zod schema — equivalent to z.infer<typeof schema>. When you call createStorage(schema), TypeScript resolves S automatically from the schema you pass. You can also use the type directly:
import type { StorageInstance } from 'km-storage';
import { z } from 'zod';
const schema = z.object({ name: z.string(), age: z.number() });
type MyStore = StorageInstance<z.infer<typeof schema>>;
// → StorageInstance<{ name: string; age: number }>Advanced Usage
sessionStorage Mode
const sessionStore = createStorage(schema, {
prefix: 'session',
mode: 'sessionStorage',
});
sessionStore.create('token', 'abc123');
// Stored in sessionStorage — cleared automatically when the tab is closedCompression
For schemas that store large objects or arrays, enable zipson compression:
const store = createStorage(
z.object({
catalog: z.array(z.object({ id: z.number(), name: z.string(), tags: z.array(z.string()) })),
}),
{ prefix: 'data', compress: true }
);
// Values are compressed on write and decompressed on read transparently
store.create('catalog', largeArray);Warning: The
compressflag must remain the same for all reads and writes on the same store instance. Do not change this flag after writing entries — existing data will become silently unreadable.
Namespaced / Prefixed Stores
Use different prefixes to create fully isolated stores for different concerns in your application:
const authStore = createStorage(
z.object({
token: z.string(),
refreshToken: z.string(),
expiresAt: z.number(),
}),
{ prefix: 'auth' }
);
const uiStore = createStorage(
z.object({
theme: z.enum(['light', 'dark']).default('light'),
sidebarOpen: z.boolean().default(true),
fontSize: z.number().default(14),
}),
{ prefix: 'ui' }
);
// localStorage keys produced:
// auth:token, auth:refreshToken, auth:expiresAt
// ui:theme, ui:sidebarOpen, ui:fontSize
// — completely isolated, no collisionReact — Watching Storage Changes
import { useEffect, useState } from 'react';
import { createStorage } from 'km-storage';
import { z } from 'zod';
const schema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
username: z.string(),
});
// Create the store once, outside the component
const store = createStorage(schema, { prefix: 'app' });
// Custom hook that stays in sync with a storage key
function useStorageValue<K extends keyof z.infer<typeof schema>>(key: K) {
const [value, setValue] = useState(() => store.read(key));
useEffect(() => {
// Re-sync with current value (might have changed since initial render)
setValue(store.read(key));
const unsubscribe = store.watch(key, (current) => setValue(current));
return unsubscribe;
}, [key]);
return value;
}
function ThemeToggle() {
const theme = useStorageValue('theme');
const toggle = () =>
store.update('theme', (prev) => (prev === 'light' ? 'dark' : 'light'));
return (
<button onClick={toggle}>
Current theme: {theme ?? 'light'}
</button>
);
}Multiple Stores with Shared Schema
const schema = z.object({
count: z.number().default(0),
label: z.string(),
});
// One schema, two storage backends
const persistent = createStorage(schema, { prefix: 'counter-ls', mode: 'localStorage' });
const temporary = createStorage(schema, { prefix: 'counter-ss', mode: 'sessionStorage' });
persistent.create('count', 100);
temporary.create('count', 5);
persistent.read('count'); // → 100
temporary.read('count'); // → 5TTL Patterns
const store = createStorage(
z.object({
accessToken: z.string(),
cachedSearch: z.array(z.object({ id: z.number(), title: z.string() })),
oneTimeCode: z.string(),
}),
{ prefix: 'api' }
);
// Access token valid for 15 minutes
store.create('accessToken', 'Bearer tok_xyz', { ttl: 15 * 60 * 1000 });
// Cache search results for 5 minutes
store.create('cachedSearch', results, { ttl: 5 * 60 * 1000 });
// One-time code expires in 30 seconds
store.create('oneTimeCode', 'XK-99', { ttl: 30_000 });
// On each user action, extend the session
store.update('accessToken', (prev) => prev ?? 'Bearer tok_xyz', { ttl: 15 * 60 * 1000 });
// Reads auto-clear expired entries from storage
const token = store.read('accessToken');
if (!token) {
// Token expired — refresh it
await refreshToken();
}Export Styles
km-storage supports three import styles — use whichever fits your codebase:
Named exports (recommended)
import { createStorage, zodCast, encodeEntry, decodeEntry, isPlainObject, isJsonString } from 'km-storage';
import type { StorageOptions, StorageInstance, CreateOptions, WatchCallback, Updater } from 'km-storage';Default namespace export
import kmStorage from 'km-storage';
const store = kmStorage.createStorage(schema, { prefix: 'app' });CommonJS
const { createStorage } = require('km-storage');
// or via the default export
const kmStorage = require('km-storage');
const store = kmStorage.default.createStorage(schema, { prefix: 'app' });Version Compatibility
| km-storage | TypeScript | Zod | Node.js |
|------------|-----------|-----|---------|
| 0.3.x | 4.9.x | ^3.23 | >=10 |
| 1.3.x | ~5.9 | >=4.0.0 | >=14 |
Browser Compatibility
km-storage uses the standard Web Storage API (localStorage, sessionStorage) and the storage event — available in all evergreen browsers.
| Browser | Minimum Version | |---------|----------------| | Chrome | 4+ | | Firefox | 3.5+ | | Safari | 4+ | | Edge | 12+ | | Opera | 10.5+ |
Note: km-storage is SSR-safe as of
v1.3.5.createStoragecan be called unconditionally in SSR frameworks (Next.js, Nuxt, SvelteKit). All operations are no-ops on the server — reads returnundefined, writes are silently skipped — so your app never crashes on the server and hydrates normally in the browser.
FAQ
Q: Why Zod 4.x and not 3.x?
Zod 4 has a significantly smaller bundle size, faster parse times, and a cleaner internal API. km-storage's zodCast uses Zod's internal _def.type discriminator which changed between v3 (ZodString, ZodNumber …) and v4 ('string', 'number' …). Zod 4.x (^4.4.0) is required.
Q: What happens if my schema changes after data is already stored?
When a schema field changes type (e.g. z.string() → z.number()), read tries to coerce the old stored value to the new type via zodCast. If coercion fails, it returns undefined. No data is overwritten. You can migrate by calling create with the corrected value.
Q: Can I store null, false, or 0 values?
Yes. km-storage uses an envelope pattern {"v": value}, so falsy primitives (0, false, null, "") are stored and retrieved correctly without being confused with "missing data". Only undefined and a missing key indicate that no data was written.
Q: Is the data encrypted?
No. km-storage does not encrypt data. localStorage and sessionStorage are accessible to any JavaScript running on the same origin. Do not store sensitive secrets (passwords, private keys, PII) in Web Storage.
Q: Does watch() fire for changes in the same tab?
Yes. km-storage calls notify() synchronously after every create, update, and remove call, so same-tab watchers always fire immediately. Cross-tab watchers use the browser's storage event, which fires in other tabs — not the originating one.
Q: What is the storage size limit?
localStorage is limited to approximately 5 MB per origin (varies by browser). With compress: true, payloads are typically 30–70% smaller. For very large data sets, consider IndexedDB.
Q: What is zodCast useful for outside of km-storage?
zodCast is useful wherever you receive untyped string or unknown data and need to convert it to a typed value with a Zod schema:
- URL parameters:
zodCast(z.number().min(1), searchParams.get('page'))—'3'→3 - Environment variables:
zodCast(z.boolean(), process.env.DEBUG)—'true'→true - Form fields:
zodCast(z.enum(['asc','desc']), formData.get('sort'))—'asc'→'asc' - API response coercion: when a backend returns
"42"where you expect42
Q: Can I use km-storage without TypeScript?
Yes. The compiled package ships as plain JavaScript (ESM, CJS, and Universal builds). You lose type safety and intellisense, but the runtime behavior is identical.
Contributing
Bug reports and pull requests are welcome on GitHub.
# Clone and install
git clone https://github.com/komeilm76/km-storage.git
cd km-storage
bun install
# Run tests
bun run test
# Run tests with coverage report
bun run test:coverage
# Build all output formats
bun run buildLicense
MIT — free to use, modify, and distribute.
