メインコンテンツまでスキップ

第18章 環境依存①:ブラウザ/Nodeの差をDIで吸収🪟🌐

この章は「同じTypeScriptでも、動かす場所が変わると使えるAPIが変わる😵」問題を、DIでスッキリ解決しちゃう回だよ〜🧹💖 (本日時点だと Node は v24 がActive LTS、v25 がCurrent だよ📌)(Node.js)


この章でできるようになること🎯✨

  • ブラウザ専用の localStorageアプリの中心から追い出す🚚💨
  • Node側では fs/promises を使った保存に 差し替えできるようにする🗄️✨(Node.js)
  • 「選ぶのは入口(Composition Root)だけ!」の形にできる📍💕

1) 環境依存ってなに?🤔🌍

代表例はこんな感じ👇

  • ブラウザ:localStorage がある(キー/値で保存できる)🧺 setItem / getItem / removeItem などがあるよ(MDNウェブドキュメント)
  • Node:localStorage は基本ない(代わりにファイルやDBなど)📄 ファイルなら fs/promises が Promise で扱えて便利だよ(Node.js)

ここでやりがちな事故がこれ👇


2) ダメ例:中心のコードが localStorage 直叩き😣🧨

// ❌ ブラウザ以外で落ちやすい例
export class PrefsService {
getTheme(): string {
return localStorage.getItem("theme") ?? "light";
}
setTheme(theme: string) {
localStorage.setItem("theme", theme);
}
}

Nodeで動かした瞬間、localStorage is not defined 💥 みたいにコケるやつ〜😭


3) 解決の型:契約(interface)→ 実装2つ → 入口で注入📍💉

まず「保存できる箱」の契約を作る🧩

ブラウザは同期、Nodeは非同期になりがちなので、ここは Promise で統一しちゃうのがラクだよ🫶

// src/core/KeyValueStore.ts
export interface KeyValueStore {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
}

4) アプリ中心(core/app)は「契約」だけ見て使う💖

例として「テーマ設定」を保存してみよう🎨✨

// src/core/ThemeService.ts
import type { KeyValueStore } from "./KeyValueStore";

export class ThemeService {
constructor(private readonly store: KeyValueStore) {}

async getTheme(): Promise<"light" | "dark"> {
const v = await this.store.get("theme");
return v === "dark" ? "dark" : "light";
}

async setTheme(theme: "light" | "dark"): Promise<void> {
await this.store.set("theme", theme);
}
}

ポイント✅

  • localStoragefsここに出てこない🙅‍♀️
  • 「保存できる何か」を注入すればOK💉✨

5) ブラウザ実装:LocalStorage版🪟🧺

Storage(Web Storage API)の基本メソッドを使うよ〜🧸 (getItem / setItem / removeItem など)(MDNウェブドキュメント)

// src/infra/browser/LocalStorageStore.ts
import type { KeyValueStore } from "../../core/KeyValueStore";

export class LocalStorageStore implements KeyValueStore {
constructor(private readonly storage: Storage = localStorage) {}

async get(key: string): Promise<string | null> {
return this.storage.getItem(key);
}
async set(key: string, value: string): Promise<void> {
this.storage.setItem(key, value);
}
async remove(key: string): Promise<void> {
this.storage.removeItem(key);
}
}

6) Node実装:ファイル保存版📄🗄️(fs/promises)

Nodeの fs/promises は Promiseベースで扱えるよ✨(Node.js) ただし 同じファイルに同時書き込みは注意(同期されないよ)って公式も言ってるので、ここでは「書き込みキュー」で安全寄りにするね🚦(Node.js)

// src/infra/node/JsonFileStore.ts
import type { KeyValueStore } from "../../core/KeyValueStore";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

type StoreData = Record<string, string>;

export class JsonFileStore implements KeyValueStore {
private writeQueue: Promise<void> = Promise.resolve();

constructor(private readonly filePath: string) {}

async get(key: string): Promise<string | null> {
const data = await this.readAll();
return data[key] ?? null;
}

async set(key: string, value: string): Promise<void> {
await this.enqueueWrite(async () => {
const data = await this.readAll();
data[key] = value;
await this.writeAll(data);
});
}

async remove(key: string): Promise<void> {
await this.enqueueWrite(async () => {
const data = await this.readAll();
delete data[key];
await this.writeAll(data);
});
}

private async enqueueWrite(fn: () => Promise<void>): Promise<void> {
this.writeQueue = this.writeQueue.then(fn, fn);
return this.writeQueue;
}

private async readAll(): Promise<StoreData> {
try {
const text = await readFile(this.filePath, "utf8");
const parsed = JSON.parse(text) as StoreData;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}

private async writeAll(data: StoreData): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, JSON.stringify(data, null, 2), "utf8");
}
}

7) 入口(Composition Root)で「どっちを使うか」決める📍✨

コツは 分けることif (isBrowser) を中心に混ぜるより、入口を2つ作るのが事故りにくいよ🧯

ブラウザ入口🪟

// src/entry/browser.ts
import { ThemeService } from "../core/ThemeService";
import { LocalStorageStore } from "../infra/browser/LocalStorageStore";

export function createAppForBrowser() {
const store = new LocalStorageStore();
return new ThemeService(store);
}

Node入口🌙

// src/entry/node.ts
import { ThemeService } from "../core/ThemeService";
import { JsonFileStore } from "../infra/node/JsonFileStore";

export function createAppForNode() {
const store = new JsonFileStore("./.data/app-store.json");
return new ThemeService(store);
}

パッケージとして配る場合は、Nodeの "exports" で入口を切り替える「条件付きエクスポート」もあるよ📦(require/import等で分けられる)(Node.js) ※ただ、この章の段階では「入口ファイルを分ける」が一番わかりやすくて強い🫶


8) テストは「Fake(メモリ保存)」で超ラク🧪💖

Vitest は v4 系が出てるよ🧡(Vitest)

// src/test/InMemoryStore.ts
import type { KeyValueStore } from "../core/KeyValueStore";

export class InMemoryStore implements KeyValueStore {
private data = new Map<string, string>();

async get(key: string) { return this.data.get(key) ?? null; }
async set(key: string, value: string) { this.data.set(key, value); }
async remove(key: string) { this.data.delete(key); }
}
// src/core/ThemeService.test.ts
import { describe, it, expect } from "vitest";
import { ThemeService } from "./ThemeService";
import { InMemoryStore } from "../test/InMemoryStore";

describe("ThemeService", () => {
it("default is light", async () => {
const svc = new ThemeService(new InMemoryStore());
expect(await svc.getTheme()).toBe("light");
});

it("can set dark", async () => {
const svc = new ThemeService(new InMemoryStore());
await svc.setTheme("dark");
expect(await svc.getTheme()).toBe("dark");
});
});

9) よくある地雷まとめ💣(ここ避けると勝ち🏆)

  • localStorage文字列だけ:オブジェクトは JSON.stringify/parse が必要🧊
  • Nodeのファイル保存は 同時更新に注意:fs/promises 操作は同期されないので、同一ファイルの並列変更は壊れうるよ⚠️(Node.js)
  • TSのモジュール解決は設定で挙動が変わる:moduleResolution: "bundler" は “感染する” って注意されてるよ🧫(入口分離はここでも効く!)(TypeScript)

10) ミニ課題📝🌸(手を動かすと一気に身につく!)

  1. sessionStorage版を作ってみよう🧺✨

    • LocalStorageStore をコピって SessionStorageStore にするだけでOK!
  2. ThemeService を拡張して「言語設定 language」も保存しよう🌍

    • theme と同じパターンで language を追加!
  3. Node側の保存先を ./.data/app-store.json から ./.data/{profile}.json に変えられるようにしてみよう👤✨

    • createAppForNode(profile: string) にして profile を注入💉

まとめ🎀

  • 環境依存は「中心に置く」と壊れる😣
  • 契約(interface)を作って、実装を外側に押し出すと超平和🕊️✨
  • **選ぶのは入口だけ(Composition Root)**📍💖

AIにお願いすると速いプロンプト例🤖✨

  • 「KeyValueStore の Node 版実装を、同一ファイルの同時書き込みに配慮して作って」🗄️
  • 「ThemeService のテストを Vitest で3本作って。FakeはInMemoryで」🧪
  • 「入口を browser/node で分けた最小の構成ツリーを提案して」🗂️✨

次は、第19章(env/config をDIで安全に扱う🎛️✨)へ進めてもいいし、今の章の題材を「ToDo保存」にして実践版にしてもOKだよ〜🥳📌