- Author
- app-hacks
- Published
TypeScript / Web Storage / Browser API
Choosing Between localStorage and sessionStorage and Building a Safe Wrapper
Use Web Storage safely in SSR projects, validate parsed JSON, and distinguish unavailable storage, quota failures, and serialization errors in TypeScript.
Web Storage is convenient for small client-side state, but it does not guarantee that writes succeed, parsed JSON has the expected shape, or browser APIs exist in the current runtime. Centralize conversion and failures instead of scattering direct storage calls across components.
Choose storage by lifetime
localStorage persists for the same origin after the browser closes, making it suitable for device-local preferences such as theme or list order. sessionStorage is scoped to a tab session and suits temporary values needed only during a short navigation flow.
Both store string key-value pairs. They are not large caches or searchable databases, and they must not hold secrets such as authentication tokens: an XSS payload running on the same origin can read them.
Do not access window during SSR
Astro frontmatter runs during build or on the server, where window and localStorage do not exist.
---
const defaultTheme = 'light'; // Do not call localStorage here.
---
<button id="theme-toggle" type="button">Toggle theme</button>
<script>
let savedTheme: string | null = null;
try {
savedTheme = localStorage.getItem('theme');
} catch {
// Use the default when storage access is blocked.
}
document.documentElement.dataset.theme = savedTheme ?? 'light';
</script>
Put browser-only work in a client script. Storage access itself can be blocked, so even simple initialization should fall back to a default. Shared modules should check typeof window === 'undefined'; if (!window) still evaluates an undefined identifier and can throw.
Return failures with a Result type
type StorageError =
| { type: 'unavailable' }
| { type: 'quota-exceeded' }
| { type: 'serialize-failed'; cause: unknown }
| { type: 'parse-failed'; cause: unknown };
type Result<T> = { ok: true; value: T } | { ok: false; error: StorageError };
function isQuotaExceeded(error: unknown): boolean {
return error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.code === 22);
}
Prefer the standard exception name over matching human-readable messages. Other DOM exceptions can mean storage is blocked, so do not classify every failure as a full quota.
Implement a validated JSON wrapper
type Validator<T> = (value: unknown) => value is T;
export class JsonStorage {
constructor(private readonly storage: Storage | undefined) {}
get<T>(key: string, validate: Validator<T>): Result<T | null> {
if (!this.storage) return { ok: false, error: { type: 'unavailable' } };
let raw: string | null;
try { raw = this.storage.getItem(key); }
catch { return { ok: false, error: { type: 'unavailable' } }; }
if (raw === null) return { ok: true, value: null };
try {
const value: unknown = JSON.parse(raw);
return validate(value)
? { ok: true, value }
: { ok: false, error: { type: 'parse-failed', cause: 'Invalid shape' } };
} catch (cause: unknown) {
return { ok: false, error: { type: 'parse-failed', cause } };
}
}
set<T>(key: string, value: T): Result<void> {
if (!this.storage) return { ok: false, error: { type: 'unavailable' } };
let serialized: string;
try { serialized = JSON.stringify(value); }
catch (cause: unknown) { return { ok: false, error: { type: 'serialize-failed', cause } }; }
try { this.storage.setItem(key, serialized); return { ok: true, value: undefined }; }
catch (cause: unknown) {
return isQuotaExceeded(cause)
? { ok: false, error: { type: 'quota-exceeded' } }
: { ok: false, error: { type: 'unavailable' } };
}
}
}
window.localStorage and getItem() can throw when storage is blocked. JSON.stringify() can fail for circular structures or BigInt, so classify it separately from setItem(). Valid JSON can still have the wrong structure; require a validator when reading.
type Preferences = { theme: 'light' | 'dark'; compact: boolean };
const isPreferences = (value: unknown): value is Preferences => {
if (typeof value !== 'object' || value === null) return false;
const data = value as Record<string, unknown>;
return (data.theme === 'light' || data.theme === 'dark') && typeof data.compact === 'boolean';
};
function getLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') return undefined;
try { return window.localStorage; }
catch { return undefined; }
}
const storage = new JsonStorage(getLocalStorage());
const result = storage.get('preferences:v1', isPreferences);
if (result.ok && result.value) applyPreferences(result.value);
Versioned keys reduce accidental reads after schema changes. Web Storage is synchronous, so keep values small and avoid writing on every input event. The wrapper should not pretend storage never fails; it should expose enough information for callers to choose a default, notification, deletion, or migration.