TypeScript and the runtime · 7 / 7
lesson 7

Local storage and untrusted persistence

Yesterday's data is a stranger. Versioning and migrations on read keep the boundary honest.

~ 16 min read·lesson 7 of 7
0 / 7

Your app saved a settings object to localStorage six months ago. You shipped a new version with three new fields. A returning user opens the app, and the saved blob doesn't match what your code expects. The bug isn't that the user did anything wrong — they didn't. The bug is that you treated stored data as if it would always be the shape you wrote two releases back. This lesson is about what to do instead.

What localStorage gives you

The browser's persistent key-value store has a small, honest API.

storage-api.ts
localStorage.setItem("settings", JSON.stringify(settings));
const raw = localStorage.getItem("settings");
/* raw is string | null */

getItem returns string | null — the string if a value was stored, null if the key was never written. That's the entire surface. The store doesn't know about types, doesn't know about JSON, doesn't promise the value you wrote is the value you read.

Two things can go wrong before you even reach validation. The string might not parse — JSON.parse throws on malformed input. And the parsed value, even if syntactically fine, might not be the shape your code expects.

storage-careful.ts
function loadSettings(): Settings | null {
const raw = localStorage.getItem("settings");
if (raw === null) return null;

let parsed: unknown;
try {
  parsed = JSON.parse(raw);
} catch {
  return null;   /* corrupt — fall back */
}

/* parsed is unknown — still not Settings */
return parsed as Settings;   /* the lie returns */
}

The first half of this is fine — handling null, catching parse errors. The last line is the same compile-time lie you saw with fetch. The cast doesn't check the shape. A user who hand-edited their localStorage, or whose data was written by an older app version, gets through the gate.

Watch out

JSON.parse throws on invalid JSON, but it does not throw on JSON that's valid but wrong-shaped. "42" parses fine; it's just not a settings object. The throw protects you from corrupt strings, not from old shapes.

check your understanding
You read localStorage.getItem("user") and wrap it in a try / catch around JSON.parse. The try succeeds. What is the type of the parsed value, honestly?

Yesterday's shape

Here's the part that makes persistence different from fetch. With fetch, the data you receive was written now by a server you can deploy alongside the client. With localStorage, the data you read was written whenever the user last saved it — which could be a year ago, by a version of your app that no longer exists.

old-shape.ts
/* v1 — shipped six months ago */
type Settings = { theme: "light" | "dark"; fontSize: number };

/* v2 — shipped today */
type Settings = {
theme: "light" | "dark" | "auto";
fontSize: number;
density: "compact" | "comfortable";
};

A user who installed v1, set their preferences, and opened v2 today has a stored object missing density. Your v2 code expects it. If you cast and read, settings.density is undefined, and the conditional that compares it to "compact" silently fails. The user sees a layout that looks subtly broken and never knows why.

The fix is to expect this. The data you read might be from an older version of your code, and you have to recognize that and do something useful — either bring it up to date, or throw it away and start fresh.

v1 6mo ago theme, fontSizev2 3mo ago + densityv3 (now) + accent migrate on read
Stored data is a snapshot from whenever the user last wrote it. The reading code may not be the writing code.

Versioned schemas

The trick that scales: store a version field with the data, and switch on it when you read.

versioned-write.ts
type SettingsV3 = {
version: 3;
theme: "light" | "dark" | "auto";
fontSize: number;
density: "compact" | "comfortable";
accent: string;
};

function saveSettings(settings: SettingsV3) {
localStorage.setItem("settings", JSON.stringify(settings));
}

The version: 3 is a literal — TypeScript treats it as the constant 3, not just any number. When v4 ships, the new type uses version: 4, and old saves stay marked with their original number. Each saved object knows what shape it was when it was written.

The reader uses the version to dispatch:

versioned-read.ts
function loadSettings(): SettingsV3 | null {
const raw = localStorage.getItem("settings");
if (raw === null) return null;

let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return null; }

if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
  return null;
}

const version = (parsed as { version: unknown }).version;
if (version === 3) return parsed as SettingsV3;
if (version === 2) return migrateV2toV3(parsed as SettingsV2);
if (version === 1) return migrateV1toV3(parsed as SettingsV1);
return null;
}

The version tag turns "old shape" from a guess into a fact. If you find a version: 2, you know exactly what fields existed and what's missing. The migration function fills in the gaps.

The bare casts in the dispatcher are honest at this point: the version tag, plus the migration function's own type signature, give you enough to trust the shape after the migration. For paranoia, you can run a Zod parse on each version before migrating — same idea, more defense.

Tip

Always include the version tag in the schema, not just at the wrapper level. A bare object without a version is from v1 (the version before you started tagging) — handle that case explicitly.

Migrating on read

A migration function takes the old shape and returns the new one. It runs once per old user, the first time they open the new version.

migrations.ts
type SettingsV1 = { version: 1; theme: "light" | "dark"; fontSize: number };
type SettingsV2 = { version: 2; theme: "light" | "dark" | "auto"; fontSize: number; density: "compact" | "comfortable" };
type SettingsV3 = { version: 3; theme: "light" | "dark" | "auto"; fontSize: number; density: "compact" | "comfortable"; accent: string };

function migrateV1toV2(old: SettingsV1): SettingsV2 {
return {
  version: 2,
  theme: old.theme,
  fontSize: old.fontSize,
  density: "comfortable",   /* a sensible default */
};
}

function migrateV2toV3(old: SettingsV2): SettingsV3 {
return { ...old, version: 3, accent: "#4a90e2" };
}

function migrateV1toV3(old: SettingsV1): SettingsV3 {
return migrateV2toV3(migrateV1toV2(old));
}

Two patterns to notice. First, each migration handles one version stepV1 to V2, V2 to V3. Then migrateV1toV3 is the composition. Writing one mega-migration that handles every old version directly is a maintenance nightmare; chaining single-step migrations is mechanical and easy to test.

Second, the migrations use sensible defaults, not invented data. density: "comfortable" is what a fresh install would get for that field. Don't try to guess what the user "would have wanted" — pick the same default a brand-new user would get, and let them change it from there.

When the migration finishes, save the migrated value back so next time the read is fast.

save-after-migrate.ts
function loadSettings(): SettingsV3 | null {
/* ... dispatch as before ... */
const result = /* migrated value */;
if (result) {
  localStorage.setItem("settings", JSON.stringify(result));
}
return result;
}

After this runs once for an old user, their stored data is now version: 3. Subsequent reads skip the migration entirely.

check your understanding
You ship v3 with a new accent field. Your migration sets it to undefined. What goes wrong?
check your understanding
Your v1 saved { theme: "dark", fontSize: 14 } with no version tag. v2 expects { version: 2, ... }. What's the cleanest dispatch for the old data?

A persistence helper

The pattern generalizes. A helper that takes a key and a current schema, handles parsing, falls back on failure, and surfaces errors honestly.

storage-helper.ts
import { z } from "zod";

export function readStored<T>(
key: string,
schema: z.ZodType<T>,
fallback: T,
): T {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;

let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return fallback; }

const result = schema.safeParse(parsed);
if (!result.success) {
  log.warn("stored '" + key + "' invalid; using fallback", result.error.issues);
  return fallback;
}
return result.data;
}

readStored covers the three failure modes: nothing stored, malformed JSON, wrong shape. Each falls back to a default the caller provides. The fallback is the call site's job — different keys want different defaults, and the helper shouldn't guess.

The schema for a versioned object would be a discriminated union over version:

versioned-schema.ts
const SettingsSchema = z.discriminatedUnion("version", [
z.object({ version: z.literal(1), theme: z.enum(["light", "dark"]), fontSize: z.number() }),
z.object({ version: z.literal(2), /* ... */ }),
z.object({ version: z.literal(3), /* ... */ }),
]);

const stored = readStored("settings", SettingsSchema, defaultSettings);
const current = migrateToCurrent(stored);   /* always returns v3 */

The schema parses any valid version. The migration function gets a tagged union and dispatches on stored.version. By the time the rest of the app sees the value, it's the current shape — and you didn't have to write a single hand-rolled type guard.

This is the full move. Validate at the boundary, version your data, migrate on read, fall back on failure. The trust boundary you started with in lesson 1 is now defended at every gate the app has: types from your code, fetch responses, and storage that outlives any one release. The compiler trusts the values past the gate because the runtime checked.

check your understanding
Your stored object's schema is a discriminated union over version. Why is this a better default than a single schema with .optional() on the new fields?
check your understanding
A user opens your app after a year. You've shipped v2, v3, v4 in that time. The stored data is v1. What's the cleanest pipeline to bring them up to v4?
check your understanding
You write back the migrated settings to localStorage after reading. Why?
← prevfinish course →
KeepLearningcertificate
for completing
TypeScript and the runtime
0 of 7 read