The short answer
Read and write through two small helpers: readJson handles the missing key and bad JSON, and its @template lets each caller say what it expects:
/**
* @template T
* @param {string} key
* @param {T} fallback
* @returns {T}
*/
export function readJson(key, fallback) {
const text = localStorage.getItem(key);
if (text === null) return fallback;
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
/**
* @param {string} key
* @param {unknown} value
*/
export function writeJson(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
/** @typedef {{ sku: string, quantity: number }} CartLine */
/** @type {CartLine[]} */
const cart = readJson("cart", []);
cart.push({ sku: "LAMP-01", quantity: 1 });
writeJson("cart", cart);
sessionStorage.setItem("lastVisit", new Date().toISOString());export function readJson<T>(key: string, fallback: T): T {
const text = localStorage.getItem(key);
if (text === null) return fallback;
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
export function writeJson(key: string, value: unknown): void {
localStorage.setItem(key, JSON.stringify(value));
}
type CartLine = { sku: string; quantity: number };
const cart: CartLine[] = readJson("cart", []);
cart.push({ sku: "LAMP-01", quantity: 1 });
writeJson("cart", cart);
sessionStorage.setItem("lastVisit", new Date().toISOString());jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} cart is a CartLine[], so cart.push checks each line, and nothing else in the program touches getItem or JSON.parse directly.
Why it works
localStorage.getItem returns string | null: null when the key was never written or was removed. readJson handles that case and the JSON.parse error, and returns the fallback instead. The type parameter T is inferred from the fallback (or from the variable’s @type, as for cart), so the helper serves every key.
The same thing in TypeScript
The TypeScript equivalent tab writes the helper as readJson<T>(key: string, fallback: T): T. JavaScript cannot pass <CartLine[]> at the call, so the @type on cart does that job.
What goes wrong
Passing getItem’s result straight to JSON.parse fails because it may be null, and setItem takes only strings (TS2345 for both):
const saved = localStorage.getItem("cart");
const cart = JSON.parse(saved);error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'.
Type 'null' is not assignable to type 'string'.
localStorage.setItem("count", 3);error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
console.log(cart);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} Gotchas
- Storage can throw. Browsers may refuse storage (private windows, full quota, blocked site data); wrap
setItemintrywhen losing the value matters. - Numbers come back as strings.
localStorage.setItem("count", String(3))stores"3"; read it withNumber(…)or throughreadJson. - Dates do not survive JSON. A
Dateis stored as its ISO string; type the stored shape withstringand convert when you read it.
Questions
Is the type of the stored value checked when it is read back?
No. readJson returns whatever JSON.parse produces, typed as T on trust. Data saved by an older version of your code, or edited by hand, can be different; check it with a type guard when that matters.
Does the same code work for sessionStorage?
Yes. Both are Storage objects with the same methods; add a parameter of type Storage to the helpers to use either one.
Sources
- Window.localStorage · MDN Web Docs
- Storage: getItem() method · MDN Web Docs
Last reviewed 21 October 2026.