TypeScriptとWeb Storage
この記事ではTypeScriptとWeb Storageについて説明します。
TypeScriptとWeb Storageについて実際的なサンプルを含めて解説します。
YouTube Video
TypeScriptとWeb Storage
ブラウザの Web Storage は「キー/文字列」のストレージです。同期 API で軽量ですが、文字列だけを保存できる点や容量超過などの例外の取り扱いに注意が必要です。TypeScript を組み合わせると、型安全と安全なシリアライズ/デシリアライズ、キーの一元管理、有効期限やバージョン管理などを加えて、実運用に耐える設計になります。
localStorage と sessionStorage
localStorage はブラウザを閉じても残る永続的なストレージで、sessionStorage はタブやウィンドウ単位でのセッションストレージでタブを閉じると消えます。どちらもキー・バリュー(文字列)で値を保存します。
1// Simple usage example: store and retrieve a string
2// This example uses localStorage to persist a username.
3localStorage.setItem('username', 'alice');
4console.log('saved username:', localStorage.getItem('username')); // "alice"
5
6// session storage example
7sessionStorage.setItem('session-id', 'xyz-123');
8console.log('session id:', sessionStorage.getItem('session-id')); // "xyz-123"
- このコードは文字列の保存と取得のサンプルです。
Web Storageは文字列しか保存できないため、オブジェクトは JSON に変換して保存する必要があります。
JSONパースを利用する例
Web Storageへのオブジェクトの保存や復元には、JSON.stringify / JSON.parse を用います。
1// Simple write & read with JSON
2// Store a small object as JSON and read it back.
3const user = { id: 1, name: "Alice" };
4localStorage.setItem("app:user", JSON.stringify(user));
5
6const raw = localStorage.getItem("app:user");
7const parsed = raw ? (JSON.parse(raw) as { id: number; name: string }) : null;
8
9console.log("user:", parsed); // user: { id: 1, name: "Alice" }
- このコードは最小の動作例です。実際のアプリではパース失敗や容量不足などの例外も考慮します。
JSONパースの例外処理の例
ここでは、読み書きを安全にするラッパーを用意し、JSON.parse の失敗や setItem の例外を扱います。
1// Safe JSON helpers
2// These helpers handle parse errors and memory/storage issues separately.
3export function safeParseJson<T>(raw: string | null): T | null {
4 if (raw == null) return null;
5
6 try {
7 return JSON.parse(raw) as T;
8 } catch (error: any) {
9 if (error instanceof SyntaxError) {
10 console.error("JSON parse error:", error.message);
11 return null;
12 }
13
14 console.error("Unexpected JSON error:", error);
15 return null;
16 }
17}
18
19// Safe setter for JSON values
20export function setJson(storage: Storage, key: string, value: unknown): void {
21 try {
22 const json = JSON.stringify(value);
23 storage.setItem(key, json);
24 } catch (error: any) {
25 if (error?.name === "QuotaExceededError") {
26 console.error("Storage quota exceeded while saving JSON:", error.message);
27 } else if (error instanceof TypeError) {
28 console.error("JSON serialization failed:", error.message);
29 } else {
30 console.error("Unexpected error while setting JSON:", error);
31 }
32 }
33}
34
35// Safe getter for JSON values
36export function getJson<T>(storage: Storage, key: string, fallback?: T): T | null {
37 const parsed = safeParseJson<T>(storage.getItem(key));
38 return parsed ?? (fallback ?? null);
39}- このユーティリティはアプリ全体で使い回せます。
try/catchを加えたい場面では更にラップします。
TTL(有効期限)付き保存の例
Web Storage 自体に TTL がないため、値に expiresAt を持たせて管理します。
1// TTL wrapper
2// Store { value, expiresAt } and automatically expire items on read.
3type WithTTL<T> = { value: T; expiresAt: number | null };
4
5export function setWithTTL<T>(storage: Storage, key: string, value: T, ttlMs?: number) {
6 const payload: WithTTL<T> = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
7 setJson(storage, key, payload);
8}
9
10export function getWithTTL<T>(storage: Storage, key: string): T | null {
11 const payload = getJson<WithTTL<T>>(storage, key);
12 if (!payload) return null;
13 if (payload.expiresAt && Date.now() > payload.expiresAt) {
14 storage.removeItem(key);
15 return null;
16 }
17 return payload.value;
18}- TTL はキャッシュや自動保存(draft)に有効で、不整合を減らせます。
キーの一元管理と名前空間(衝突防止)の例
キーを prefix + version + name で統一すると、衝突やマイグレーションが楽になります。
1// Key factory with namespacing and versioning
2// Create namespaced keys like "myapp:v1:theme" to avoid collisions.
3const APP = "myapp";
4const V = "v1";
5
6const ns = (k: string) => `${APP}:${V}:${k}`;
7
8const Keys = {
9 theme: ns("theme"),
10 user: ns("user"),
11 cart: ns("cart"),
12 draft: ns("draft"),
13};- 名前空間をキーに含めることで、後でバージョン切替やクリーンアップを行いやすくなります。
容量超過(QuotaExceededError)とフォールバック戦略の例
setItem 実行時に QuotaExceededError が発生する可能性を考慮し、データの保存に失敗した場合のフォールバック戦略を設計します。たとえば、保存容量を超えた際には古いデータを削除したり、sessionStorage やメモリ上のキャッシュに退避することで、アプリ全体の安定性を維持できます。
1// Quota-safe set with fallback to in-memory storage
2// Return true if stored, false otherwise.
3export function trySetJson(storage: Storage, key: string, value: unknown, fallback?: Map<string, string>): boolean {
4 try {
5 storage.setItem(key, JSON.stringify(value));
6 return true;
7 } catch (err) {
8 console.warn("Failed to set item:", key, err);
9 if (fallback) {
10 try {
11 fallback.set(key, JSON.stringify(value));
12 return true;
13 } catch {
14 return false;
15 }
16 }
17 return false;
18 }
19}
20
21// Example fallback usage
22const inMemoryFallback = new Map<string, string>();
23const ok = trySetJson(localStorage, Keys.cart, { items: [] }, inMemoryFallback);
24if (!ok) console.log("Saved to fallback map instead");- フォールバック先によってはデータの永続性が保証されない場合があります。そのため、利用目的に応じて適切な保存先を選択します。たとえば、プライベートブラウジングモードや容量制限下では、一時的にメモリや
sessionStorageを利用することで、機能を維持できます。
クロスタブ同期 (storage イベント) と同一タブ通知の例
window.addEventListener('storage', …) を利用すると、他のタブで発生したストレージの変更を検知できます。ただし、このイベントは同一タブ内では発火しません。そのため、同一タブ内での変更通知には CustomEvent を使用して独自にイベントを発行します。
1// Cross-tab and same-tab notification helpers
2// storage event fires on other tabs; use CustomEvent for same-tab listeners.
3const SAME_TAB_EVENT = "storage:changed";
4
5function notifyChanged(key: string) {
6 window.dispatchEvent(new CustomEvent(SAME_TAB_EVENT, { detail: { key } }));
7}
8
9function setJsonWithNotify(storage: Storage, key: string, value: unknown) {
10 setJson(storage, key, value);
11 notifyChanged(key);
12}
13
14// Listeners
15window.addEventListener("storage", (e) => {
16 if (e.key === Keys.theme) {
17 const theme = safeParseJson<string>(e.newValue);
18 console.log("Theme changed in another tab:", theme);
19 }
20});
21
22window.addEventListener(SAME_TAB_EVENT, (e: Event) => {
23 const detail = (e as CustomEvent).detail as { key: string };
24 console.log("Changed in this tab:", detail.key);
25});- このコードにより、他のタブと現在のタブの両方でストレージの変更を同期させることができます。
型安全レジストリ(キーごとに型を厳格化)
TypeScript でキーと型のマップを定義して、保存や取得時に誤りが起きないようにします。
1// Typed registry that enforces types per key
2// Registry maps keys to their allowed types.
3type Registry = {
4 [k in typeof Keys.theme]: "light" | "dark";
5} & {
6 [k in typeof Keys.user]: { id: number; name: string };
7};
8
9type KeyOf<R> = Extract<keyof R, string>;
10
11export const TypedStore = {
12 get<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): Registry[K] | null {
13 return getJson<Registry[K]>(storage, key);
14 },
15 set<K extends KeyOf<Registry>>(key: K, value: Registry[K], storage: Storage = localStorage): void {
16 setJson(storage, key, value);
17 },
18 remove<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): void {
19 storage.removeItem(key);
20 },
21};- {^ i18n_speak 型をキーに関連付けることで、ランタイムでの誤使用をコンパイル時に検出でき、安全性を高めることができます。 ^}
複雑な型(Date / Map 等)のシリアライズ/リバイバー
Date や Map といったオブジェクトを正しく復元するには、JSON.stringify の replacer と reviver を活用します。
1// Serializing Dates with replacer and reviver
2// Custom replacer marks Date objects for correct revival.
3function replacer(_k: string, v: unknown) {
4 if (v instanceof Date) return { __type: "Date", value: v.toISOString() };
5 return v;
6}
7
8function reviver(_k: string, v: any) {
9 if (v && v.__type === "Date") return new Date(v.value);
10 return v;
11}
12
13function setJsonWithDates(storage: Storage, key: string, value: unknown) {
14 storage.setItem(key, JSON.stringify(value, replacer));
15}
16
17function getJsonWithDates<T>(storage: Storage, key: string): T | null {
18 const raw = storage.getItem(key);
19 if (!raw) return null;
20 try { return JSON.parse(raw, reviver) as T; } catch { return null; }
21}- この方法を使うと、
Dateオブジェクトが正しくDateとして復元されます。同様に、MapやSetもマーキングして復元可能です。
バージョニングとマイグレーション戦略の例
ストレージフォーマットを将来変更する可能性があるときは、ペイロードにバージョンを含めてマイグレーションを用意します。
1// Versioned payload pattern for migrations
2// Keep { v, data } and migrate on read if necessary.
3type VersionedPayload<T> = { v: number; data: T };
4
5function migrateUserV1toV2(u1: { id: number; name: string }) {
6 return { id: u1.id, profile: { displayName: u1.name } };
7}
8
9function readUserAnyVersion(): { id: number; profile: { displayName: string } } | null {
10 const raw = localStorage.getItem(Keys.user);
11 if (!raw) return null;
12 try {
13 const obj = JSON.parse(raw) as VersionedPayload<any>;
14 if (obj.v === 2) {
15 return obj.data;
16 } else if (obj.v === 1) {
17 const migrated = migrateUserV1toV2(obj.data);
18 localStorage.setItem(Keys.user, JSON.stringify({ v: 2, data: migrated }));
19 return migrated;
20 }
21 return null;
22 } catch (err) {
23 console.error("migration parse error", err);
24 return null;
25 }
26}- 小さなマイグレーションを積み重ねることで、後方互換を保てます。
SSR(サーバサイドレンダリング)での扱いの例
window がない環境では直接 localStorage を参照するとクラッシュするため、環境ガードを使います。
1// Guard for SSR
2// Return a Storage-compatible object or null when not in browser.
3export const isBrowser = (): boolean => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
4
5export const safeLocalStorage = (): Storage | null => (isBrowser() ? window.localStorage : null);
6
7// Usage
8const ls = safeLocalStorage();
9if (ls) setJson(ls, Keys.theme, "dark");- SSR対応が必要なコードは常に
typeof windowチェックを忘れないでください。
実戦 Tips
- 書き込み頻度は debounce / throttle で抑える(UI 操作のバースト対策)。
- キーは
app:v1:...のように名前空間 + バージョンで管理する。 - 敏感情報(アクセストークン等)は基本的に保存しない。どうしてもなら短寿命+サーバ検証や WebCrypto を検討する。
- 容量はブラウザ依存(約数MB)であるため大量データは IndexedDB へ。
- 同一タブ通知は
CustomEvent、クロスタブはstorageを利用する。 - SSR では
typeof windowを必ずチェックする。
まとめ用「型安全ストア」クラス
名前空間や型安全、有効期限、例外処理など、ここまで紹介した要素を統合した汎用クラスの実装例を見てみましょう。実際のプロダクトでは、テストやログ出力、LRU による古いデータ削除、暗号化などの追加を検討してください。
1// Comprehensive TypedStorage store integrating many patterns shown above.
2// - type-safe registry per key
3// - prefix (namespace + version)
4// - trySet with fallback
5// - same-tab notify
6// - TTL optional getter/setter
7type Jsonifiable = string | number | boolean | null | Jsonifiable[] | { [k: string]: Jsonifiable };
8
9interface StoreOptions {
10 storage?: Storage | null; // default: auto-detected localStorage or null
11 prefix?: string; // e.g., "myapp:v1"
12 sameTabEvent?: string | null;
13 fallback?: Map<string, string>; // in-memory fallback
14}- このコードは、型安全で多機能なキー・バリュー型ストレージを構築するための設定や型定義を示しています。
1export class TypedStorage<Reg extends Record<string, Jsonifiable | object>> {
2 private storage: Storage | null;
3 private prefix: string;
4 private sameTabEvent: string | null;
5 private fallback?: Map<string, string>;
6
7 constructor(private registry: Reg, opts: StoreOptions = {}) {
8 this.storage = opts.storage ?? (typeof window !== "undefined" ? window.localStorage : null);
9 this.prefix = (opts.prefix ?? "app:v1") + ":";
10 this.sameTabEvent = opts.sameTabEvent ?? "storage:changed";
11 this.fallback = opts.fallback;
12 }
13
14 private k(key: keyof Reg & string) { return this.prefix + key; }- このコードは、型安全なキー・バリューストレージを提供する
TypedStorageクラスの基盤部分で、registryに基づき保存可能なキーと型を管理し、接頭辞付きのストレージキーを生成します。さらに、localStorageやメモリフォールバックを利用し、同一タブ内の変更通知用イベント名も設定できます。
1 // Basic get with optional TTL-aware retrieval
2 get<K extends keyof Reg & string>(key: K): Reg[K] | null {
3 const fullKey = this.k(key);
4 try {
5 const raw = this.storage ? this.storage.getItem(fullKey) : this.fallback?.get(fullKey) ?? null;
6 if (!raw) return null;
7 // Check if value is TTL-wrapped
8 const maybe = safeParseJson<{ value: Reg[K]; expiresAt?: number }>(raw);
9 if (maybe && typeof maybe.expiresAt === "number") {
10 if (maybe.expiresAt && Date.now() > maybe.expiresAt) {
11 this.remove(key);
12 return null;
13 }
14 return maybe.value;
15 }
16 return safeParseJson<Reg[K]>(raw);
17 } catch (err) {
18 console.error("TypedStorage.get error", err);
19 return null;
20 }
21 }getメソッドは、指定したキーの値を型安全に取得する関数で、オプションで TTL(有効期限)付きの値も扱います。
1 // Basic set; returns success boolean
2 set<K extends keyof Reg & string>(key: K, value: Reg[K]): boolean {
3 const fullKey = this.k(key);
4 const payload = JSON.stringify(value);
5 try {
6 if (this.storage) this.storage.setItem(fullKey, payload);
7 else this.fallback?.set(fullKey, payload);
8 if (this.sameTabEvent) window.dispatchEvent(new CustomEvent(this.sameTabEvent, { detail: { key: fullKey } }));
9 return true;
10 } catch (err) {
11 console.warn("TypedStorage.set primary failed, trying fallback", err);
12 try {
13 if (this.fallback) {
14 this.fallback.set(fullKey, payload);
15 return true;
16 }
17 return false;
18 } catch (e) {
19 console.error("TypedStorage.set fallback failed", e);
20 return false;
21 }
22 }
23 }setメソッドは、指定したキーに値を保存し、成功したかどうかをブール値で返す関数です。
1 // Set with TTL convenience
2 setWithTTL<K extends keyof Reg & string>(key: K, value: Reg[K], ttlMs?: number): boolean {
3 const payload = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
4 return this.set(key, payload as unknown as Reg[K]);
5 }setWithTTLメソッドは、値を TTL(有効期限)付きで保存します。
1 remove<K extends keyof Reg & string>(key: K) {
2 const fullKey = this.k(key);
3 try {
4 if (this.storage) this.storage.removeItem(fullKey);
5 this.fallback?.delete(fullKey);
6 } catch (err) { console.warn("TypedStorage.remove error", err); }
7 }removeメソッドは、指定したキーの値をストレージおよびフォールバックから削除する関数です。
1 clear() {
2 try {
3 if (this.storage) this.storage.clear();
4 this.fallback?.clear();
5 } catch (err) { console.warn("TypedStorage.clear error", err); }
6 }
7}clearメソッドは、ストレージとフォールバックに保存されているすべてのデータを削除する関数です。
1// Usage example
2type MyReg = {
3 theme: "light" | "dark";
4 user: { id: number; name: string };
5 draft: string;
6};
7
8const memFallback = new Map<string, string>();
9const store = new TypedStorage<MyReg>({} as MyReg, {
10 prefix: "myapp:v1",
11 sameTabEvent: "storage:changed",
12 fallback: memFallback
13});
14store.set("theme", "dark");
15console.log(store.get("theme")); // "dark"
16store.setWithTTL("draft", "in-progress...", 1000 * 60 * 60); // keep 1 hour
-
このコードは
TypedStorageを使った例で、型安全なキー・バリューストアに"theme"や"draft"などの値を保存・取得し、TTL やフォールバックも利用できます。同一タブ通知やメモリフォールバックを設定して、安全にストレージ操作を行っています。 -
TypedStorageクラスは実用的な起点です。必要に応じて LRU 戦略、暗号化、圧縮、IndexedDB へのフォールバックなどを実装してください。
まとめ
TypeScript で Web Storage を使う際は「型安全」「例外耐性」「セキュリティ」「同期(複数タブ)」の4点を常に意識すると堅牢な設計になります。ここまでで見てきたラッパーやユーティリティは、その実装例です。必要に応じて IndexedDB 等の他のブラウザストレージに移行することもできます。
YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。