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

第33章:Singleton ② TypeScriptの定番:モジュールのexportで十分📦

1) まず結論:Singletonクラス、作らなくてOKだよ〜🙆‍♀️💡

TypeScript(というかES Modules)では、**「ファイル(モジュール)を1回だけ実行して、結果を使い回す」**のが基本の動きだよ〜📦 つまり、モジュールの中で作ったインスタンスをそのまま「export」すれば、それがもう “Singletonっぽい共有” になるの🥳

モジュールexport=共有ボックス。

(モジュールは基本「一度だけ評価(実行)される」って説明されてるよ)(MDN Web Docs)


2) なんで「export」でSingletonになるの?🤔✨

ざっくり言うと…

  • 「import」されたモジュールは、同じものなら同じ実体を参照する📦
  • だから「モジュール内に1個だけ作ったもの」をexportすれば、アプリのどこから使っても“それ”になる🎯

Node.jsでも、ESMは公式の標準形式で、URLとして解決してキャッシュされる(=同じURLなら同じモジュール扱い)って説明があるよ〜🧠(Node.js) TypeScript側も、ESMの「import/export」文法をそのまま理解して扱うよ〜📘(TypeScript)


3) いちばん定番:モジュールで共有インスタンスをexportする🧁📦

「Logger」みたいな共有物はこの形がめちゃ定番だよ〜😊✨

// logger.ts
export type LogLevel = "debug" | "info" | "warn" | "error";

export interface Logger {
debug(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
warn(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
}

function write(level: LogLevel, message: string, data?: unknown) {
const prefix = `[${new Date().toISOString()}] ${level.toUpperCase()}:`;
if (data === undefined) {
console.log(prefix, message);
} else {
console.log(prefix, message, data);
}
}

// ✅ これが「モジュールSingleton」になる!
export const logger: Logger = {
debug: (m, d) => write("debug", m, d),
info: (m, d) => write("info", m, d),
warn: (m, d) => write("warn", m, d),
error: (m, d) => write("error", m, d),
};

ポイント💡

  • クラスでSingleton実装しない
  • 標準の「console」を使う(TS/JSで超定番)😄
  • exportした「logger」が共有物になる📦✨

4) ハンズオン:注文処理でloggerを使ってみよう☕🧾📣

注文の合計を計算して、確定ログを出すよ〜🎉

// orderService.ts
import { logger } from "./logger.js";

export type OrderItem = { name: string; price: number; qty: number };

export function calcTotal(items: OrderItem[]): number {
return items.reduce((sum, it) => sum + it.price * it.qty, 0);
}

export function placeOrder(items: OrderItem[]) {
const total = calcTotal(items);
logger.info("order placed", { total, itemCount: items.length });
return { total };
}
// app.ts
import { placeOrder } from "./orderService.js";

placeOrder([
{ name: "Latte", price: 520, qty: 1 },
{ name: "Cookie", price: 220, qty: 2 },
]);

💥「.jsって何?」ってなりがちポイント

ESMでは、実行時(Node.js)の都合で相対importに拡張子が必要なケースがあるよ〜🌀 TypeScriptの公式ドキュメントでも、例として「./module.js」みたいなimportが普通に出てくるよ📘(TypeScript) (ここで詰まったら「tsconfigのmodule/moduleResolution」を見直すやつ!)(TypeScript)


5) “モジュールSingleton”の落とし穴⚠️(複数になるパターン)

「え、同じloggerのはずなのに、2個ある…😇」ってなるときはだいたいこれ👇

① importの“指定の仕方”が違う

Node.jsのESMは、解決結果がURLとして扱われてキャッシュされるよ📦 なので、同じファイルでも別URL扱いになると、別モジュールとしてロードされちゃうことがあるの⚠️ 例:クエリ(?)やフラグメント(#)が違うと別扱い、みたいな話も出てくるよ🧠(Node.js)

✅ 対策

  • importパスの書き方をプロジェクトで統一する(表記ゆれ禁止)🧹✨
  • エイリアスを使うなら、入口を1つに決める📌

② 開発中のHMR/ホットリロード

開発サーバーの仕組みで「モジュールを差し替え」ると、再評価が起きることがあるよ〜🔥 (これはツール側の都合なので、ログに“初期化ログ”が何回も出て気づくことが多い👀)

✅ 対策

  • モジュール初期化で副作用(勝手に接続する等)をなるべく避ける🙅‍♀️
  • 初期化が必要なら「明示的にinit関数」を用意する(でもやりすぎ注意)⚙️

6) テストで困るやつ:モジュールの状態が残る🧪😵

モジュールに「Mapキャッシュ」とか「カウンタ」とかを持つと、テストが影響し合いがち💦 なので、**共有物はできるだけ不変(読み取り中心)**に寄せるのがコツだよ〜🧊✨

どうしても状態が必要なら:最小のリセット口を作る🧹

(“オレオレクラス”じゃなくて、標準のMap+関数で十分👍)

// priceCache.ts
const priceCache = new Map<string, number>();

export function getCachedPrice(name: string) {
return priceCache.get(name);
}

export function setCachedPrice(name: string, price: number) {
priceCache.set(name, price);
}

// テストだけで使う想定の「掃除関数」🧹
export function __resetCacheForTest() {
priceCache.clear();
}

Jestを使う場合の別解:モジュールごとリセット

Jestには「モジュールレジストリをリセット」する機能があるよ🧪 代表が「resetModules」!(TypeScript)


7) いつ“export Singleton”でOK?いつ危ない?🧭✨

✅ わりとOK(やりやすい)

  • ロガー(今回のloggerみたいなやつ)📝
  • 読み取り専用の設定(定数の塊)⚙️
  • 純粋なユーティリティ(状態を持たない)🧰

⚠️ 慎重に(次章の「注入」にしたくなる)

  • DB接続 / APIクライアント(失敗や再接続が絡む)🌩️
  • キャッシュ(寿命・削除・競合が絡む)🗃️
  • グローバルに増減する状態(テスト地獄の入口)😇

「状態が強い・外部I/Oが強い」ほど、モジュールSingletonは事故りやすいよ〜⚠️ ここが次章(注入で差し替え)につながる流れだよ💉✨


8) ミニ演習🎯✨(手を動かすやつ!)

演習A:loggerに「注文ID」を入れよう🧾🆔

  • placeOrderの引数に「orderId: string」を追加
  • logger.infoのdataに「orderId」を入れてみよう📣

演習B:loggerを“静かモード”にできるようにしよう🤫

  • logger.tsに「ログレベル」を追加(debug/info/warn/error)
  • レベル以下は出さないようにする(if 1個でOK)✨

演習C:キャッシュを持つモジュールを作って、テストでリセット🧪🧹

  • Mapを使って「商品名→価格」を保存
  • テスト前に「__resetCacheForTest」を呼んで安定させる✅

9) AIプロンプト例🤖💬(コピペOK)

  • 「logger.tsを、標準consoleだけで、最小構成で作って。export constで共有インスタンスにして」
  • 「ESMのimportで同じモジュールが複数になる原因を、具体例つきで3つ」
  • 「Mapキャッシュを持つモジュールのテスト戦略を、状態漏れの観点でレビューして」

10) まとめ🎉📦

  • Singletonクラスを頑張る前に、“モジュールのexport”でだいたい解決するよ〜📦✨
  • モジュールは基本「一度だけ評価」されるから共有が自然にできるよ😊(MDN Web Docs)
  • でも「状態が強い共有」はテストや不具合の温床になりやすい⚠️
  • そのときは「状態を減らす」or「テストでリセット」or「次章の注入(DIっぽい)」が効くよ💉✨