本文へ移動
aapp-hacks
← 記事一覧へ
著者
app-hacks
公開日

TypeScript / Web Storage / Browser API

localStorageとsessionStorageの使い分けと安全なラッパークラスの作成

Web Storage APIをSSR環境でも安全に扱い、JSON破損や容量超過を分類するTypeScript実装を解説する。

Web Storage APIは小さなクライアント状態を保存するには便利だが、保存できたこと、読み込んだJSONが正しいこと、ブラウザ環境で実行されていることを自動では保証しない。localStoragesessionStorageを直接呼ぶコードが各コンポーネントへ散ると、例外処理とデータ移行も散らばる。用途を決め、変換と失敗を一つのラッパーへ閉じ込める。

二つのストレージを用途で分ける

localStorageは同一オリジンでブラウザを閉じた後も残る。テーマ、表示密度、記事一覧の並び順など、次回訪問でも復元したい端末ローカル設定に向く。sessionStorageはタブ単位のページセッションで保持される。入力途中の一時値や、一連の画面遷移だけで必要な状態に向く。

どちらも文字列のkey-value保存であり、大きなキャッシュや検索可能なデータベースではない。認証トークンなど、JavaScriptから参照されると困る秘密情報の保管場所にもならない。XSSが起きれば同じオリジンのスクリプトから読めるためである。

SSRでwindowを直接参照しない

Astroコンポーネントのフロントマターはビルド時またはサーバー側で実行される。そこにはwindowlocalStorageも存在しない。

---
// ここで localStorage.getItem(...) を呼ばない
const defaultTheme = 'light';
---

<button id="theme-toggle" type="button">テーマを切り替える</button>

<script>
  const savedTheme = localStorage.getItem('theme');
  document.documentElement.dataset.theme = savedTheme ?? 'light';
</script>

ブラウザ専用処理は<script>内、またはクライアントでのみ実行されるコンポーネントのライフサイクルへ置く。共用TypeScriptモジュールで環境を判定するなら、typeof window === 'undefined'を使う。if (!window)は識別子を評価した時点でReferenceErrorになるため安全ではない。

Result型で失敗を返す

Storageの読み書きは例外を投げ得る。容量超過、利用制限、壊れたJSONを呼び出し側が区別できる形にする。

type StorageError =
  | { type: 'unavailable' }
  | { type: 'quota-exceeded' }
  | { type: 'serialize-failed'; cause: unknown }
  | { type: 'parse-failed'; cause: unknown };

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: StorageError };

function isQuotaExceeded(error: unknown): boolean {
  return (
    error instanceof DOMException &&
    (error.name === 'QuotaExceededError' || error.code === 22)
  );
}

QuotaExceededErrorの判定をメッセージ文字列に依存させない。例外がDOMExceptionであることを確認し、標準のnameを優先する。code === 22は互換性の補助として扱う。ストレージが無効な環境では別名のDOMExceptionが発生することもあるため、それらをすべて容量超過と決めつけない。

型付きラッパーを実装する

読み込み時のvalidatorを必須にし、JSONをunknownから目的の型へ絞り込む。

type Validator<T> = (value: unknown) => value is T;

export class JsonStorage {
  constructor(private readonly storage: Storage | undefined) {}

  get<T>(key: string, validate: Validator<T>): Result<T | null> {
    if (!this.storage) return { ok: false, error: { type: 'unavailable' } };

    const raw = this.storage.getItem(key);
    if (raw === null) return { ok: true, value: null };

    try {
      const value: unknown = JSON.parse(raw);
      if (!validate(value)) {
        return { ok: false, error: { type: 'parse-failed', cause: 'Invalid shape' } };
      }
      return { ok: true, value };
    } catch (cause: unknown) {
      return { ok: false, error: { type: 'parse-failed', cause } };
    }
  }

  set<T>(key: string, value: T): Result<void> {
    if (!this.storage) return { ok: false, error: { type: 'unavailable' } };

    let serialized: string;
    try {
      serialized = JSON.stringify(value);
    } catch (cause: unknown) {
      return { ok: false, error: { type: 'serialize-failed', cause } };
    }

    try {
      this.storage.setItem(key, serialized);
      return { ok: true, value: undefined };
    } catch (cause: unknown) {
      return isQuotaExceeded(cause)
        ? { ok: false, error: { type: 'quota-exceeded' } }
        : { ok: false, error: { type: 'unavailable' } };
    }
  }
}

JSON.stringify()も常に成功するわけではない。循環参照を含むオブジェクトやBigIntでは例外になり得るため、setItem()とは別のtryブロックで分類する。読み込み時は、JSONとして正しくても期待する構造とは限らない。古いバージョンのデータや手動で変更された値を考慮し、validatorを通す。

ブラウザ側でインスタンスを作る処理は次のようになる。

type Preferences = { theme: 'light' | 'dark'; compact: boolean };

const isPreferences = (value: unknown): value is Preferences => {
  if (typeof value !== 'object' || value === null) return false;
  const data = value as Record<string, unknown>;
  return (
    (data.theme === 'light' || data.theme === 'dark') &&
    typeof data.compact === 'boolean'
  );
};

const storage = new JsonStorage(
  typeof window === 'undefined' ? undefined : window.localStorage,
);
const result = storage.get('preferences:v1', isPreferences);

if (result.ok && result.value) {
  applyPreferences(result.value);
}

keyにはpreferences:v1のようにバージョンを含めると、構造変更時に旧データを誤読しにくい。移行が必要なら、旧keyを専用validatorで読み、新形式へ変換してから新keyへ保存する。失敗時に既定値へ戻すだけでよいデータと、ユーザーへ通知すべきデータも分ける。

Web Storageは同期APIなので、大量データを頻繁に読み書きするとメインスレッドを占有する。保存対象を小さな設定へ限定し、入力イベントごとの書き込みを避ける。大量データや複雑な検索が必要ならIndexedDBなど別の保存手段を検討する。

安全なラッパーの役割は、Storageを絶対に失敗しないAPIへ見せかけることではない。利用不可、容量超過、変換失敗を明示し、呼び出し側が既定値、通知、削除、再保存のどれを選ぶか判断できる状態にすることである。