Классический паттерн Снимок (Memento) часто сводится к поверхностной инкапсуляции: TypeScript
private ломается через as any или Object.keys, а runtime-доступ к состоянию остается открытым. В production — будь то state management в SPA, логирование в Node.js-сервисах или rollback в API-клиентах — это приводит к неявным мутациям и багам. Решение лежит в комбинации branded типов и WeakMap.Branded type как физический барьер
Memento — не контейнер данных, а заглушка с уникальным символом:
declare const MementoBrand: unique symbol;
interface Memento {
readonly [MementoBrand]: void;
}
Снаружи невозможно прочитать
[MementoBrand] — он просто не существует в runtime. Любая попытка as any не даст доступа к реальному состоянию, так как его там нет.WeakMap: приватность без утечек
Реальное состояние хранится в
WeakMap<Memento, State>. Ключ — сам объект Memento, который генерируется внутри класса:const stateMap = new WeakMap<Memento, { balance: number }>();
class BankAccount {
private balance = 0;
createSnapshot(): Memento {
const memento = {} as Memento;
stateMap.set(memento, { balance: this.balance });
return memento;
}
restore(memento: Memento) {
const state = stateMap.get(memento);
if (state) this.balance = state.balance;
}
}Сборщик мусора автоматически удаляет запись, когда снимок больше не используется. Никаких утечек.
Практический совет и типичная ошибка
Сериализовать такой Memento напрямую в JSON нельзя — WeakMap не итерируется. Решение: если нужна де/сериализация, замени WeakMap на
Map<string, State> и генерируй UUID для снимка. Теряешь автогарбаж, но получаешь поддержку persistence.*Типичная ошибка:* пытаться сделать Memento как простой объект с полями. Даже
readonly в TS не защитит от Object.assign или деструктуризации в модульных тестах.Вывод: Branded types в паре с WeakMap превращают TypeScript из системы типов в инструмент настоящей инкапсуляции, где приватность гарантируется на уровне исполнения, а не только компиляции.