Design Patterns in TypeScript · Part 1 — Singleton & the Module Pattern
When (and when not) to share a single instance: the Singleton pattern, why ESM modules are already singletons, lazy init, the testability trap, and typed implementations — with exercises.
This is Part 1 of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đây là Phần 1 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần}. Patterns are not academic trivia {Pattern không phải kiến thức hàn lâm để khoe}: they are a shared vocabulary for problems you already solve every day {chúng là từ vựng chung cho những vấn đề bạn vẫn giải mỗi ngày}.
We start with the most famous — and most misused — pattern: the Singleton {Ta bắt đầu với pattern nổi tiếng nhất — và bị lạm dụng nhất: Singleton}.
The intent {Ý đồ}
A Singleton ensures a class has exactly one instance and gives a global access point to it {Singleton đảm bảo một class có đúng một thực thể và cung cấp điểm truy cập toàn cục tới nó}. You reach for it when one shared thing must back the whole app {Bạn dùng nó khi một thứ dùng chung phải đứng sau cả app}: a config object, a logger, a DB connection pool, an in-memory cache, a feature-flag client {một object config, một logger, một connection pool DB, một cache in-memory, một client feature-flag}.
The danger is also in that definition {Hiểm họa cũng nằm ngay trong định nghĩa đó}: “global access point” is just “global variable” wearing a suit {“điểm truy cập toàn cục” chỉ là “biến toàn cục” mặc vest}. We’ll use it deliberately, not by reflex {Ta sẽ dùng nó có chủ đích, không phải theo phản xạ}.
Naive global vs Singleton {Biến global ngây thơ vs Singleton}
The naive version is a mutable module global {Bản ngây thơ là một biến global ở module có thể ghi đè}:
// ❌ anyone can reassign it, no guarantees, no lazy init
export let config = { apiUrl: '' };
The Singleton’s job is to make “exactly one, created once” a guarantee, not a convention {Việc của Singleton là biến “đúng một, tạo một lần” thành đảm bảo, không phải quy ước}.
The classic class Singleton {Singleton dạng class kinh điển}
The textbook form: a private constructor (nobody can new it) and a static accessor that lazily creates the one instance {Dạng sách giáo khoa: constructor private (không ai new được) và một accessor static tạo thực thể duy nhất một cách lười}:
class AppConfig {
// The single instance, created on first access.
static #instance: AppConfig | null = null;
readonly apiUrl: string;
readonly env: 'dev' | 'prod';
// Private: callers cannot do `new AppConfig()`.
private constructor() {
this.apiUrl = process.env.API_URL ?? 'http://localhost:3000';
this.env = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
}
static get(): AppConfig {
// Lazy init: build it once, reuse forever.
this.#instance ??= new AppConfig();
return this.#instance;
}
}
const a = AppConfig.get();
const b = AppConfig.get();
console.log(a === b); // true — same instance
Key pieces {Các mảnh ghép then chốt}: the #instance private static field holds the one copy {trường static private #instance giữ bản duy nhất}; the private constructor blocks new {constructor private chặn new}; and ??= gives lazy initialization — nothing is built until first use {và ??= cho khởi tạo lười — không tạo gì cho tới lần dùng đầu}.
The module is already a singleton {Module vốn đã là singleton}
Here’s the part most tutorials skip {Đây là phần đa số tutorial bỏ qua}: in ESM (and CommonJS), a module is evaluated once and its exports are cached {trong ESM (và CommonJS), một module được đánh giá một lần và export của nó được cache}. Every import of the same module gets the same bindings {Mọi lần import cùng một module nhận cùng binding}. So the idiomatic TypeScript singleton is often just a module {Vì vậy singleton idiomatic trong TypeScript thường chỉ là một module}:
// config.ts — evaluated once, shared everywhere it's imported
function createConfig() {
return {
apiUrl: process.env.API_URL ?? 'http://localhost:3000',
env: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
} as const;
}
export const config = createConfig();
// a.ts and b.ts both:
import { config } from './config';
// → identical object, created exactly once
This is simpler than the class form, has no getInstance() ceremony, and is just as guaranteed by the module system {Cách này đơn giản hơn dạng class, không cần nghi thức getInstance(), và vẫn được hệ module đảm bảo như nhau}. Prefer it for stateless or read-only shared values {Hãy ưu tiên nó cho giá trị dùng chung read-only hoặc không trạng thái}.
Reach for the class form only when you need truly lazy construction (expensive to build, may never be used) or an explicit lifecycle (
connect()/dispose()) {Chỉ dùng dạng class khi bạn cần khởi tạo thật sự lười (đắt để dựng, có thể không bao giờ dùng) hoặc một vòng đời rõ ràng (connect()/dispose())}.
Real web use cases {Use case web thực tế}
- Config / feature flags — one source of truth, read everywhere {một nguồn sự thật, đọc khắp nơi}.
- Logger — one configured instance with shared transports {một instance đã cấu hình, dùng chung transport}.
- HTTP client — a single
fetchwrapper holding base URL, auth interceptors, retry policy {một wrapperfetchduy nhất giữ base URL, interceptor auth, chính sách retry}. - Client-side store / cache — one in-memory cache so two components don’t double-fetch {một cache in-memory để hai component không fetch trùng}.
- DB connection pool (Node) — you want one pool, not one per request {bạn muốn một pool, không phải mỗi request một cái}.
The testability trap {Bẫy khó test}
A Singleton is hidden global state, and global state is the enemy of tests {Singleton là trạng thái toàn cục ẩn, và trạng thái toàn cục là kẻ thù của test}. Two problems show up {Hai vấn đề xuất hiện}: state leaks between tests (test A mutates the singleton, test B inherits it), and you can’t substitute a fake in a unit test {trạng thái rò rỉ giữa các test; và bạn không thể thay một bản giả khi unit test}.
The fix is dependency injection: depend on the type, pass the instance in {Cách sửa là dependency injection: phụ thuộc vào kiểu, truyền instance vào}. Keep the singleton at the composition root, but let the code under test receive it as an argument {Giữ singleton ở composition root, nhưng để code cần test nhận nó qua tham số} (we dedicate Part 10 to this) {(ta dành hẳn Phần 10 cho việc này)}:
interface Clock {
now(): number;
}
// The real singleton lives at the edge of the app...
export const systemClock: Clock = { now: () => Date.now() };
// ...but logic depends on the interface, so tests pass a fake.
export function isExpired(token: { exp: number }, clock: Clock): boolean {
return token.exp < clock.now();
}
// test: isExpired(t, { now: () => 1_000 }) — no global, fully deterministic
Anti-patterns to avoid {Anti-pattern cần tránh}: using a singleton as a dumping ground for unrelated global state {dùng singleton làm bãi rác cho state global không liên quan}; mutable singletons shared across requests on a server (one user’s data leaks to another) {singleton có thể ghi, dùng chung giữa các request trên server (dữ liệu user này rò sang user khác)}; and “singletons everywhere” instead of passing dependencies {và “singleton ở mọi nơi” thay vì truyền dependency}.
Cheat sheet {Bảng tra nhanh}
// Module singleton — preferred for shared read-only values
export const config = createConfig();
// Class singleton — when you need lazy build or a lifecycle
class Pool {
static #i: Pool | null = null;
private constructor() {}
static get() { return (this.#i ??= new Pool()); }
}
// Testable: depend on an interface, inject the instance
function useIt(dep: Service) { /* ... */ }
Decision: stateless & shared → module; lazy/lifecycle → class; needs testing → inject the interface {Quyết định: không trạng thái & dùng chung → module; lười/vòng đời → class; cần test → inject interface}.
Bài tập / Exercises
1. Implement a Logger singleton (class form) with a level and a log(msg) method, and prove Logger.get() === Logger.get() {Cài một singleton Logger (dạng class) có level và phương thức log(msg), và chứng minh Logger.get() === Logger.get()}.
Solution {Lời giải}
class Logger {
static #instance: Logger | null = null;
level: 'debug' | 'info' = 'info';
private constructor() {}
static get(): Logger {
return (this.#instance ??= new Logger());
}
log(msg: string): void {
console.log(`[${this.level}] ${msg}`);
}
}
console.log(Logger.get() === Logger.get()); // true2. Rewrite the same logger as a module singleton (no class). Which version is shorter, and what did you give up? {Viết lại logger đó thành module singleton (không class). Bản nào ngắn hơn, và bạn đánh đổi điều gì?}
Solution {Lời giải}
// logger.ts
const state = { level: 'info' as 'debug' | 'info' };
export const logger = {
setLevel(l: 'debug' | 'info') { state.level = l; },
log(msg: string) { console.log(`[${state.level}] ${msg}`); },
};Shorter, no getInstance() ceremony {Ngắn hơn, không nghi thức getInstance()}. You give up truly lazy construction — the module runs its top-level code on first import {Bạn đánh đổi khởi tạo thật lười — module chạy code top-level ngay lần import đầu}.
3. The function getUserAge(birthYear) reads new Date().getFullYear() directly, so its tests break every January 1st {Hàm getUserAge(birthYear) đọc thẳng new Date().getFullYear(), nên test của nó hỏng mỗi mùng 1 tháng 1}. Refactor it to be deterministic using dependency injection {Refactor để nó tất định bằng dependency injection}.
Solution {Lời giải}
interface Clock { now(): Date; }
export const systemClock: Clock = { now: () => new Date() };
export function getUserAge(birthYear: number, clock: Clock = systemClock): number {
return clock.now().getFullYear() - birthYear;
}
// test — fully deterministic, no real date:
getUserAge(1990, { now: () => new Date('2025-06-01') }); // 35Stretch {Nâng cao}: make a Counter module singleton, import it from two files, increment in one and read in the other — confirm they share state. Then explain why this is risky on a server handling concurrent requests {tạo một Counter module singleton, import từ hai file, tăng ở file này và đọc ở file kia — xác nhận chúng dùng chung state. Rồi giải thích vì sao điều này nguy hiểm trên server xử lý request đồng thời}.
Key takeaways {Điểm chính}
- Singleton = one instance + global access; powerful but easy to abuse {Singleton = một instance + truy cập toàn cục; mạnh nhưng dễ lạm dụng}.
- In TS/ESM, a module is already a singleton — prefer it for shared read-only values {Trong TS/ESM, module vốn đã là singleton — ưu tiên nó cho giá trị dùng chung read-only}.
- Use the class form only for lazy construction or an explicit lifecycle {Dùng dạng class chỉ cho khởi tạo lười hoặc vòng đời rõ ràng}.
- Singletons are hidden global state — inject an interface so code stays testable {Singleton là state toàn cục ẩn — inject interface để code vẫn test được}.
Next up {Tiếp theo}
Part 2 — Factory & Abstract Factory: stop scattering new and switch across your code; centralize object creation behind typed factory functions and discriminated unions {Phần 2 — Factory & Abstract Factory: ngừng rải new và switch khắp code; gom việc tạo object sau các factory function có kiểu và discriminated union}.