Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~22 min· topic 6 of 6

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".

index.tswhole filets
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.

index.tswhole filets
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"); // error

03Read 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.

Read as unknown, check, fall backdiagram
Rendering diagram…

04Query strings

URLSearchParams.get returns string | null, so convert and check. getAll returns every value of a repeated name.

index.tswhole filets
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.

index.tswhole filets
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. 1

    Break the link between key and value

    In the first example add storage.set("visits", "three");. Predict the error: the value must be number because the key is "visits". Then try storage.set("colour", "red");.

  2. 2

    Add a key

    Add fontSize: number to StorageShape. TypeScript immediately complains that guards is missing fontSize: the mapped type forces a guard for every key. Add it.

  3. 3

    Count clicks only

    In the store example add a third button reset that sets { count: 0, clicks: 0 }. Then remove clicks from the type: every place that used it becomes an error.

Code & diagrams

A typed storage wrapper that checks what it reads

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
undefined
URLSearchParams and URL

get 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
null
structuredClone: a deep copy, and what it can't copy

The 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
DataCloneError
A small typed store with a page

The 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

Press Run to see the page.

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");.

terminal
$ Types panel
── what you'll see ──
index.ts(33,23): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

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.

  • ▸

    structuredClone is typed structuredClone<T = any>(value: T, options?: StructuredSerializeOptions): T. Because the return type is the input type, a Map<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 QuotaExceededError when full, and setItem can also throw in some private modes. Wrap writes in try/catch in real apps, and use IndexedDB for anything big.

  • ▸

    A storage event fires in other tabs when localStorage changes. Its type is StorageEvent with key, oldValue and newValue, each string | null. Listening to it is the way to sync a typed store across tabs; run your guard on newValue again.

Remember this

  1. 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) and sessionStorage (kept until the tab closes) store strings only, under string keys. The type is Storage: getItem(key): string | null, setItem(key, value: string): void, removeItem, clear, and a length. Reading a key that doesn't exist gives null, not undefined. Storing a number turns it into text ("3"); storing an object gives the useless "[object Object]". So you convert yourself with JSON.stringify on the way in and JSON.parse on the way out.

  2. 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 to unknown, check it, and fall back to a default when it is wrong. JSON.parse also throws on text that isn't JSON, so wrap it in try/catch.

  3. 3

    A typed wrapper makes this safe in one place. Describe your storage as one object type, { theme: "light" | "dark"; visits: number }, and write get<K extends keyof Shape>(key: K): Shape[K] | undefined and set<K extends keyof Shape>(key: K, value: Shape[K]). The generic K links the key to its value type: set("visits", "three") is now a compile error, and get("theme") is known to return the theme union or undefined (Topic 8.3 explains the indexed access Shape[K]). The wrapper checks on read with one guard per key, so the types stay honest.

  4. 4

    **URLSearchParams** reads and builds the query part of a URL (?q=ts&page=2). get(name) returns string | null (not a number, not undefined), getAll(name) returns string[], has, set, append, delete, and toString(). Everything is text, so page needs Number(...) and a check for NaN. new URL("https://example.com/search?q=ts") gives a URL object whose searchParams is one of these.

  5. 5

    **structuredClone(value)** makes a deep copy that is independent of the original: copied objects, arrays, Map, Set and Date all stay what they are (unlike the old JSON.parse(JSON.stringify(x)) trick, which turns a Date into a string and drops undefined). Its type is structuredClone<T>(value: T): T, so the copy has the same type. The catch: it throws a DataCloneError for 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 valid T. It also drops class prototypes: a cloned class instance becomes a plain object.

  6. 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 one S. subscribe(listener) returns a function that removes the listener (a cleanup function, the same idea as using in Topic 13.5). State updates take a function (prev: S) => S so you never mutate the old state. Pairing it with typed storage gives a store that survives a page reload.

  7. 7

    A note about this course's playground: localStorage in 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

01

Why must data read from localStorage be validated even if your own code wrote it?

02

How does a generic get<K extends keyof Shape>(key: K): Shape[K] improve a storage wrapper?

03

What does URLSearchParams.get return and why does that matter?

04

What are the differences between structuredClone and JSON.parse(JSON.stringify(x))?

Practice

01

Write function readQuery(search: string): { q: string; page: number } using URLSearchParams, with defaults "" and 1, where an invalid page also becomes 1.

02

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.parse with 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 localStorage stores and what getItem returns.

  • I can read stored JSON as unknown, validate it and fall back safely.

  • I can write a generic typed get/set keyed by an object type.

  • I can read and build query strings with URLSearchParams.

  • I can explain what structuredClone copies and what makes it throw.

  • I can write a small typed store with get, set and subscribe.