@yeshyungseok/storo
v0.1.0
Published
Zero-dependency, type-safe localStorage/sessionStorage wrapper with runtime validation driven by your existing TypeScript types.
Downloads
73
Maintainers
Readme
storo
Type-safe localStorage / sessionStorage for the browser — zero runtime dependencies, and it reuses the TypeScript types you already have as the source of truth for runtime validation.
You write a normal type User = { ... }. storo forces you to describe a matching shape (checked against T at compile time, so it can't drift), then validates every value on the way in and out of storage. No schema DSL to learn twice, no second type declaration to keep in sync.
- Zero dependencies — nothing ships to your users but storo itself (min+gzip budget under 3 KB).
- Your types stay the source of truth —
Shape<T>is derived fromT; ifTchanges, the shape stops compiling until you fix it. - SSR-safe — on the server (no
window) reads returnnull/defaultValueand writes are no-ops. Never throws for a missingStorage. - Honest failures — validation failures and storage/JSON failures are two distinct error classes you can branch on.
Install
pnpm add @yeshyungseok/storo
# npm install @yeshyungseok/storo / yarn add @yeshyungseok/storoQuick start
import { defineStorage, string, number, literal, array, optional } from '@yeshyungseok/storo';
type User = {
name: string;
email: string;
age: number;
role: 'admin' | 'user';
tags?: string[];
};
const userStore = defineStorage<User>({
key: 'user',
shape: {
name: string({ min: 1 }),
email: string({ email: true }),
age: number({ int: true, min: 0, max: 120 }),
role: literal('admin', 'user'),
tags: optional(array(string())), // optional field (?) → wrap in optional()
},
defaultValue: { name: 'anonymous', email: '[email protected]', age: 0, role: 'user' },
storage: 'local', // 'local' | 'session', default 'local'
});
userStore.set({ name: 'Ada', email: '[email protected]', age: 30, role: 'admin' }); // validated, then stored
const u = userStore.get(); // User | null
const u2 = userStore.get({ onFailure: 'default' }); // User (defaultValue on failure)
userStore.clear(); // remove the key
userStore.init(); // set(defaultValue)Optional fields must use
optional(). Atags?: string[]inTmaps tooptional(array(string()))in the shape — not a barearray(string()). This is what tells the runtime that an absent key is allowed. A required key that is missing at read time is reported asinvalid_type(not silently skipped), which is exactly the bug that "just skip undefined keys" would let through.
Builders
string · number · boolean · literal · array · record · tuple · union · optional · nullable · custom
string({ min, max, length, regex, email, url, startsWith, endsWith });
number({ int, min, max, finite });
array(element, { min, max });
record(valueToken); // Record<string, V>
tuple([a, b, c]); // fixed-length positional
union([a, b]); // first matching branch wins
literal('admin', 'user'); // string | number | boolean literals
optional(inner); // T | undefined
nullable(inner); // T | null
custom(guard, message?); // escape hatch, see belowget() and the onFailure policy
get() reads the raw string, JSON.parses it, and validates it against the shape. "Failure" means: storage unavailable, key absent, unparseable JSON, or validation failure. onFailure decides what happens then:
| onFailure | On success | On failure | Return type |
| ------------------ | ----------------- | ---------------------- | ----------- |
| 'null' (default) | returns the value | returns null | T \| null |
| 'default' | returns the value | returns defaultValue | T |
| 'throw' | returns the value | throws (see below) | T \| null |
set, clear, and init do not take onFailure. set validates first and throws on invalid input; init is set(defaultValue).
Two error classes
Both extend Error, and instanceof reliably distinguishes them:
StorageValidationError— the value did not match the shape. Carries.issues: Issue[], each{ path, message, code }, so you can point at exactly which field failed.StorageError— a non-validation failure: unparseable JSON on read,JSON.stringifyfailure (e.g. a circular reference), or aStoragewrite/remove throwing (e.g. quota exceeded).
import { StorageError, StorageValidationError } from '@yeshyungseok/storo';
try {
const user = userStore.get({ onFailure: 'throw' });
} catch (err) {
if (err instanceof StorageValidationError) {
console.warn('stored user is stale/corrupt:', err.issues);
} else if (err instanceof StorageError) {
console.error('storage/JSON problem:', err.message);
}
}custom turns validation OFF for that node — it is your responsibility
custom(guard, message?) lets you pass any value that isn't expressible with the other builders. But the guard is the only check that runs for that node: wrapping a node in custom disables storo's validation there, and whatever the guard lets through is entirely on you. If your guard returns true for garbage, garbage is stored and later handed back to your typed code as if it were valid.
// The guard MUST actually verify the shape you claim.
custom<Point>((v): v is Point => typeof v === 'object' && v !== null && 'x' in v && 'y' in v);If a guard throws, storo treats the value as invalid (it will not crash).
number() rejects NaN and ±Infinity by default
JSON.stringify(NaN) and JSON.stringify(Infinity) both produce "null", so a stored NaN silently becomes null and the round-trip is broken. To surface this, number() rejects non-finite values by default (code: 'not_finite') — both on set and on get. If you genuinely need to store NaN/Infinity, opt out explicitly:
number(); // rejects NaN, Infinity, -Infinity
number({ finite: false }); // allows them (you accept the null round-trip)Reading type errors: look at the last line
Because storo uses an invariant phantom function to lock a shape to T (this is what makes literal('admin') fail to cover 'admin' | 'user'), a value-level mismatch is reported through that phantom function type. TypeScript prints the structural mismatch first and the actual cause last. When a shape won't compile, read the bottom line of the error — that's where the real reason is.
For example, an incompletely-covered literal:
// role is 'admin' | 'user', but the shape only lists 'admin'
const bad: Shape<'admin' | 'user'> = literal('admin');The error ends with the line that names the actual problem:
Type '"user"' is not assignable to type '"admin"'.That last line tells you the shape is missing the 'user' case — add it: literal('admin', 'user').
v1 limitations (by design)
readonlytuples/arrays are not supported — declare the type as mutable, or usecustom.- Type recursion is budgeted to depth 9 —
Shape<T>for structures deeper than 9 levels resolves tonever. Usecustomat the deep boundary. email/urlvalidation is lax — simple, permissive regexes meant to catch obvious garbage, not to be RFC-complete.- Extra keys pass through — keys present in the stored object but absent from the shape are ignored and preserved (input = output invariant, forward-compatible). A strict/reject mode is v2.
- Not in v1: cross-tab sync, schema migration/versioning, and TTL/expiry. (Because unmatched old data is handled by
onFailureasnull/default, a schema change degrades gracefully instead of corrupting data.)
Round-trip and fallback gotchas
These are consequences of "validate at the boundary, JSON in the middle." None of them corrupt data — but knowing them saves debugging time.
defaultValueis not re-validated on fallback. YourdefaultValuemust itself satisfy the shape's constraints (min,email, etc.).get({ onFailure: 'default' })returnsdefaultValuewithout re-validating it when it falls back, so a constraint-violating default can hand back an invalid value. (setandinitdo validatedefaultValueand throw, soinit()is the place a bad default surfaces.)get()returns the stored object verbatim (input = output). storo never mutates prototypes, but if untrusted storage contains a__proto__own key it is validated and then returned as an own key on the object you get back. Don't pass that object straight into an unsafe deep-merge /Object.assignsink that treats__proto__specially — storo itself does not pollute, but a careless downstream sink can.customguards see the post-JSON.parsevalue. Onget, your guard receives plain JSON (objects, arrays, primitives) — not the original class instance.instanceof Date/instanceof Map/ class guards do not round-trip (aDatecomes back as a string, aMapas{}). Prefer structural guards, or reconstruct the instance yourself afterget.- JSON normalizes on the way out.
-0round-trips to0, and an optional key whose value isundefinedis dropped on write — so'key' in objflips fromtruetofalseacross aset/getcycle. Compare by value, not by key presence. - A root
optionalstore can't storeundefined. For a top-leveloptional(...)shape,set(undefined)removes the key, so a subsequentget()returnsnull, notundefined(browser storage cannot holdundefined). Treatnullas the absent case.
Security: do not store auth tokens or sessions here
localStorage and sessionStorage are readable by any JavaScript on the page. Never store access tokens, refresh tokens, or session identifiers in browser storage — a single XSS bug then leaks every session. Keep auth material in httpOnly, secure, sameSite cookies that JavaScript cannot read. storo is for non-sensitive app state (preferences, UI state, drafts), not credentials.
License
MIT
