Topic 13.6
Typing Browser Storage and APIs
In one line
Browser storage keeps only text, so everything you read back is untrusted. Learn typed wrappers around localStorage, safe JSON.parse, URLSearchParams, structuredClone, and how to build a small typed store.
Think of it like this
A locker room with identical grey lockers. You may only put paper notes in them, so if you want to keep a toy you must write a description of it. Next week you come back, read the note and rebuild the toy. But someone else could have swapped your note for another one, or you could have changed the way you describe toys. Browser storage is that locker. You store text, you read text, and you must check the note makes sense before you rebuild anything from it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- localStorage
- A small key-value store in the browser that keeps text until it is cleared.
- sessionStorage
- Like localStorage but for one tab, gone when the tab closes.
- JSON.stringify / JSON.parse
- Turn a value into text, and text back into a value.
- Typed wrapper
- A small object that hides raw storage behind typed get and set functions.
- URLSearchParams
- A helper for reading and building the ?name=value&... part of a URL.
- structuredClone
- Makes a deep, independent copy of a value.
- DataCloneError
- The error structuredClone throws for things it can't copy, like functions.
- Store
- An object that holds state and tells listeners when it changes.
- Unsubscribe
- The function a subscribe call returns, which stops the listener.
Step by step
01Storage gives strings or null
localStorage.getItem("visits") is string | null. Even if you stored the number 3, you get back the text "3".
localStorage.setItem("visits", String(3));
const raw = localStorage.getItem("visits"); // string | null
const n = Number(raw ?? "0");02Describe the storage once
One object type lists every key and what it holds. The wrapper's generic K ties a key to its value type.
type StorageShape = {
theme: "light" | "dark";
visits: number;
};
function set<K extends keyof StorageShape>(key: K, value: StorageShape[K]) {
localStorage.setItem(key, JSON.stringify(value));
}
set("visits", 3); // fine
set("visits", "three"); // error03Read as unknown, check, fall back
Parsing can throw and can return the wrong shape. Both end in the fallback, so the caller always gets something valid.
04Query strings
URLSearchParams.get returns string | null, so convert and check. getAll returns every value of a repeated name.
const params = new URLSearchParams("?q=ts&page=2&tag=a&tag=b");
const q = params.get("q"); // string | null
const page = Number(params.get("page")); // 2
const tags = params.getAll("tag"); // string[]05A store is state, set and subscribe
The generic S is the shape of the state. set takes a function of the old state. subscribe takes a listener and returns the function that removes it.
function createStore<S>(initial: S) {
let state = initial;
const listeners = new Set<(state: S) => void>();
return {
get: () => state,
set(update: (prev: S) => S) {
state = update(state);
listeners.forEach((l) => l(state));
},
subscribe(listener: (state: S) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}Try it yourself
- 1
Break the link between key and value
In the first example add
storage.set("visits", "three");. Predict the error: the value must benumberbecause the key is"visits". Then trystorage.set("colour", "red");. - 2
Add a key
Add
fontSize: numbertoStorageShape. TypeScript immediately complains thatguardsis missingfontSize: the mapped type forces a guard for every key. Add it. - 3
Count clicks only
In the store example add a third button
resetthat sets{ count: 0, clicks: 0 }. Then removeclicksfrom the type: every place that used it becomes an error.
Code & diagrams
localStorage here is an in-memory stand-in that resets on each Run. Bad data never reaches your code: a wrong shape and invalid JSON both give undefined. Try storage.set("visits", "three") to see the key-to-value link.
Console
(nothing was printed)Expected console output
dark undefined Pune
undefined
undefinedget gives string | null and getAll gives string[]. Everything in a URL is text, so numbers need conversion, and a missing name is null.
Console
(nothing was printed)Expected console output
typescript [ 'a', 'b' ] 2
q=typescript&page=3
https://example.com/search?q=ts&sort=new
nullThe copy has the same type as the original, and changing it leaves the original alone. A function can't be cloned, and the type system doesn't know. The JSON trick keeps the type with a cast, but the data has changed.
Console
(nothing was printed)Expected console output
1 [ 'a' ] true
string
DataCloneErrorThe type Counter flows into every callback: s.count is a number in subscribe and in set. After stop() the page stops updating, but the state still changes.
Preview
Console
Nothing logged yet.Expected console output
count 11 after 2 clicks
count 11 after 2 clicks | state: { count: 12, clicks: 3 }Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Store the wrong type for a key
In the first example, after the two storage.set lines add storage.set("visits", "three");.
Myth vs fact
Myth
localStorage can store numbers and objects.
Fact
It stores strings. A number becomes text, and an object becomes "[object Object]" unless you use JSON.stringify.
Myth
JSON.parse(JSON.stringify(x)) is a good deep copy.
Fact
It turns dates into strings, drops undefined and functions, and throws on cycles. structuredClone handles dates, maps, sets and cycles.
Myth
Anything I stored myself is safe to trust.
Fact
Another tab, an older app version, browser extensions and the user can all change it. Validate on read.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
structuredCloneis typedstructuredClone<T = any>(value: T, options?: StructuredSerializeOptions): T. Because the return type is the input type, aMap<string, () => void>is accepted and fails only at runtime. Cloned class instances lose their prototype and private#fields. - ▸
Browser storage is synchronous and blocks the main thread; it holds roughly 5 MB per origin and throws
QuotaExceededErrorwhen full, andsetItemcan also throw in some private modes. Wrap writes intry/catchin real apps, and use IndexedDB for anything big. - ▸
A
storageevent fires in other tabs whenlocalStoragechanges. Its type isStorageEventwithkey,oldValueandnewValue, eachstring | null. Listening to it is the way to sync a typed store across tabs; run your guard onnewValueagain.
Remember this
- 1
The problem this solves: the browser keeps only text, and what you read back may be missing, old or edited by someone else, so trusting it blindly causes crashes. Typed wrappers and checks make sure only data of the right shape gets into your code.
localStorage(kept until cleared) andsessionStorage(kept until the tab closes) store strings only, under string keys. The type isStorage:getItem(key): string | null,setItem(key, value: string): void,removeItem,clear, and alength. Reading a key that doesn't exist givesnull, notundefined. Storing a number turns it into text ("3"); storing an object gives the useless"[object Object]". So you convert yourself withJSON.stringifyon the way in andJSON.parseon the way out. - 2
JSON.parse(text)returns **any** (Topic 13.4), and stored data is exactly the kind of untrusted input from that topic: an older version of your app wrote a different shape, another tab changed it, or a user edited it in the developer tools. So assign the result tounknown, check it, and fall back to a default when it is wrong.JSON.parsealso throws on text that isn't JSON, so wrap it intry/catch. - 3
A typed wrapper makes this safe in one place. Describe your storage as one object type,
{ theme: "light" | "dark"; visits: number }, and writeget<K extends keyof Shape>(key: K): Shape[K] | undefinedandset<K extends keyof Shape>(key: K, value: Shape[K]). The genericKlinks the key to its value type:set("visits", "three")is now a compile error, andget("theme")is known to return the theme union orundefined(Topic 8.3 explains the indexed accessShape[K]). The wrapper checks on read with one guard per key, so the types stay honest. - 4
**
URLSearchParams** reads and builds the query part of a URL (?q=ts&page=2).get(name)returnsstring | null(not a number, notundefined),getAll(name)returnsstring[],has,set,append,delete, andtoString(). Everything is text, sopageneedsNumber(...)and a check forNaN.new URL("https://example.com/search?q=ts")gives aURLobject whosesearchParamsis one of these. - 5
**
structuredClone(value)** makes a deep copy that is independent of the original: copied objects, arrays,Map,SetandDateall stay what they are (unlike the oldJSON.parse(JSON.stringify(x))trick, which turns aDateinto a string and dropsundefined). Its type isstructuredClone<T>(value: T): T, so the copy has the same type. The catch: it throws aDataCloneErrorfor things that can't be cloned, such as functions and DOM elements, and TypeScript does not warn you, because a type with a method is still a validT. It also drops class prototypes: a cloned class instance becomes a plain object. - 6
A typed store is a tiny object holding state with three methods: read it, change it, and tell listeners it changed. It is the heart of Zustand, Redux and similar libraries, and a generic
createStore<S>(initial: S)types all of it from oneS.subscribe(listener)returns a function that removes the listener (a cleanup function, the same idea asusingin Topic 13.5). State updates take a function(prev: S) => Sso you never mutate the old state. Pairing it with typed storage gives a store that survives a page reload. - 7
A note about this course's playground:
localStoragein the live examples is an in-memory stand-in that resets every time you press Run, because the preview is sandboxed. In a real page the data persists. Test persistence in your own browser's developer tools (Application tab).
Explain it without notes
Why must data read from localStorage be validated even if your own code wrote it?
How does a generic get<K extends keyof Shape>(key: K): Shape[K] improve a storage wrapper?
What does URLSearchParams.get return and why does that matter?
What are the differences between structuredClone and JSON.parse(JSON.stringify(x))?
Practice
Write function readQuery(search: string): { q: string; page: number } using URLSearchParams, with defaults "" and 1, where an invalid page also becomes 1.
Write function persist<T>(key: string, value: T): void and function restore(key: string): unknown, and use restore safely to read a number.
Trade-offs
- ↔
A typed wrapper with guards costs code per key, and gives data you can trust. A schema library (Topic 13.4) shortens it. For throwaway data, a plain
JSON.parsewith a fallback may be enough. - ↔
A hand-written store is thirty lines and fully typed. A library adds devtools, middleware and React bindings; choose it when you need those.
Done when you can
I can say what
localStoragestores and whatgetItemreturns.I can read stored JSON as
unknown, validate it and fall back safely.I can write a generic typed
get/setkeyed by an object type.I can read and build query strings with
URLSearchParams.I can explain what
structuredClonecopies and what makes it throw.I can write a small typed store with
get,setandsubscribe.