npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@yeshyungseok/storo

v0.1.0

Published

Zero-dependency, type-safe localStorage/sessionStorage wrapper with runtime validation driven by your existing TypeScript types.

Downloads

73

Readme

storo

npm version minzipped size zero dependencies types included CI license: MIT

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 truthShape<T> is derived from T; if T changes, the shape stops compiling until you fix it.
  • SSR-safe — on the server (no window) reads return null/defaultValue and writes are no-ops. Never throws for a missing Storage.
  • 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/storo

Quick 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(). A tags?: string[] in T maps to optional(array(string())) in the shape — not a bare array(string()). This is what tells the runtime that an absent key is allowed. A required key that is missing at read time is reported as invalid_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 below

get() 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.stringify failure (e.g. a circular reference), or a Storage write/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)

  • readonly tuples/arrays are not supported — declare the type as mutable, or use custom.
  • Type recursion is budgeted to depth 9Shape<T> for structures deeper than 9 levels resolves to never. Use custom at the deep boundary.
  • email / url validation 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 onFailure as null/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.

  • defaultValue is not re-validated on fallback. Your defaultValue must itself satisfy the shape's constraints (min, email, etc.). get({ onFailure: 'default' }) returns defaultValue without re-validating it when it falls back, so a constraint-violating default can hand back an invalid value. (set and init do validate defaultValue and throw, so init() 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.assign sink that treats __proto__ specially — storo itself does not pollute, but a careless downstream sink can.
  • custom guards see the post-JSON.parse value. On get, your guard receives plain JSON (objects, arrays, primitives) — not the original class instance. instanceof Date / instanceof Map / class guards do not round-trip (a Date comes back as a string, a Map as {}). Prefer structural guards, or reconstruct the instance yourself after get.
  • JSON normalizes on the way out. -0 round-trips to 0, and an optional key whose value is undefined is dropped on write — so 'key' in obj flips from true to false across a set/get cycle. Compare by value, not by key presence.
  • A root optional store can't store undefined. For a top-level optional(...) shape, set(undefined) removes the key, so a subsequent get() returns null, not undefined (browser storage cannot hold undefined). Treat null as 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