Typing the Memento Pattern in TypeScript
Introduction
The Memento pattern is a behavioral design pattern that lets you capture and externalize an object's internal state so the object can be restored to that state later without violating encapsulation. For intermediate TypeScript developers, building a robust, type-safe Memento implementation brings challenges: how to model snapshots, ensure type safety for captures/restores, minimize copying costs, and integrate with undo/redo, persistence, or collaborative systems.
In this tutorial you'll learn how to design and implement typed Memento pattern variants in TypeScript. We'll cover simple and advanced implementations using generics, immutable snapshots, deep-copy strategies, runtime guards, and memory-performance trade-offs. You'll get step-by-step examples showing how to:
- Define a flexible Memento interface that preserves type information
- Capture and restore object state safely using TypeScript types and runtime checks
- Build an undo/redo manager and integrate with command-style operations
- Optimize snapshots with structural sharing and memoization
- Persist mementos and support serialization safely
We'll also discuss patterns that pair well with the Memento pattern, such as State and Command, and link to practical resources on guarding runtime invariants and building typed modules. By the end, you'll be able to choose and implement the Memento approach that fits your app's performance and safety needs.
Background & Context
Memento is useful whenever you need time-travel, undo/redo, or checkpointing behavior while keeping the target object's encapsulation intact. In TypeScript, naive implementations may lose static guarantees or encourage unsafe casts. Strong typing helps by ensuring snapshot shapes match the source, reducing runtime errors.
TypeScript adds both opportunities and complications: generics and conditional types allow expressive memento types, but runtime behavior still requires careful handling—especially for deep copies and references to complex objects like Maps, Sets, or class instances. We will explore strategies to maintain type-safety while handling real-world data shapes.
This guide assumes you understand TypeScript basics (generics, interfaces, mapped types) and common design patterns like the Command and State patterns. If you're interested in typing related patterns, see pieces on the State pattern and typed Command implementations for design synergy.
Key Takeaways
- Understand how to model Memento types with generics and utility types.
- Capture and restore snapshots safely using shallow or deep copy strategies.
- Implement undo/redo managers and integrate mementos with Command-like APIs.
- Balance memory and CPU with memoization, structural sharing, and caches.
- Use runtime guards and assertion functions to validate snapshots.
Prerequisites & Setup
What you need before following along:
- Node.js 14+ (TypeScript compilation) and TypeScript 4.x+
- A code editor (VS Code recommended)
- Basic knowledge of TypeScript generics, mapped types, and modules
Install TypeScript if needed:
npm init -y npm install --save-dev typescript npx tsc --init
You'll be able to run the TypeScript examples by compiling with npx tsc and then node the output or using ts-node for quick iteration.
Main Tutorial Sections
1) The Minimal Typed Memento Interface
Start by modeling a simple memento interface. We want the memento to carry a typed snapshot and metadata such as timestamp and optional label.
interface Memento<T> {
readonly snapshot: T
readonly label?: string
readonly createdAt: number
}
function createMemento<T>(snapshot: T, label?: string): Memento<T> {
return { snapshot, label, createdAt: Date.now() }
}This keeps types intact: Memento
2) Shallow vs Deep Snapshots: Choosing Copy Strategy
Shallow copies are cheap but may keep references to nested objects. Deep copies are safer but more expensive. For plain JSON-compatible data, JSON.parse(JSON.stringify(obj)) is a quick approach but breaks class instances, Dates, Maps, Sets, and functions.
Example shallow snapshot:
function shallowMemento<T extends object>(obj: T) {
return createMemento({ ...obj } as T)
}Deep snapshot with structuredClone (Node 17+, modern browsers) preserves more types:
function deepMemento<T>(obj: T) {
// runtime availability check
const snap = typeof structuredClone === 'function' ? structuredClone(obj) : JSON.parse(JSON.stringify(obj))
return createMemento<T>(snap)
}If you need to capture class instances, implement custom serialize/deserialize hooks.
3) Strongly Typed State Containers and Encapsulation
Encapsulating state via module or revealing module patterns helps keep Memento responsibilities localized. Use a typed container that exposes capture/restore methods while keeping internals private.
type State = { cursor: number; items: string[] }
function createStateContainer(initial: State) {
let state = { ...initial }
return {
getState: () => ({ ...state }),
capture: () => createMemento({ ...state }),
restore: (m: Memento<State>) => { state = { ...m.snapshot } }
}
}For modular encapsulation patterns and safe exports, see our guide on revealing module patterns to pair with mementos and keep implementation private.
4) Undo/Redo Stack Manager
A practical Memento use is undo/redo. Implement a manager that pushes mementos on operations and can rollback/forward.
class UndoManager<T> {
private past: Memento<T>[] = []
private future: Memento<T>[] = []
private current?: Memento<T>
constructor(initial?: Memento<T>) { this.current = initial }
commit(m: Memento<T>) {
if (this.current) this.past.push(this.current)
this.current = m
this.future = []
}
undo(): Memento<T> | undefined {
const prev = this.past.pop()
if (!prev) return undefined
if (this.current) this.future.push(this.current)
this.current = prev
return prev
}
redo(): Memento<T> | undefined {
const next = this.future.pop()
if (!next) return undefined
if (this.current) this.past.push(this.current)
this.current = next
return next
}
}Tight integration with a Command pattern — where each command returns a memento or uses the manager to commit — yields robust undo stacks.
5) Memento with Generics and Partial Snapshots
Sometimes you only need to snapshot a subset of state. Model this with a generic selector type and mapped utilities.
type Selector<T, K extends keyof T> = (t: T) => Pick<T, K>
function createPartialMemento<T, K extends keyof T>(obj: T, keys: K[], label?: string) {
const partial = keys.reduce((acc, k) => { acc[k] = obj[k]; return acc }, {} as Pick<T, K>)
return createMemento<Pick<T, K>>(partial, label)
}Partial snapshots reduce memory and capture only what matters. For advanced HOFs and type-safe picking, explore higher-order typing techniques explained in our article on higher-order functions.
6) Runtime Guards and Assertion Functions
Static types help, but at runtime you may receive persisted mementos that don't match expected shapes. Use assertion functions or type predicates to validate snapshots and fail early.
function assertIsState(m: any): asserts m is Memento<State> {
if (typeof m !== 'object' || typeof m.createdAt !== 'number') {
throw new Error('Invalid memento')
}
}Read more on using TypeScript assertion functions and runtime type guards in our guide on assertion functions and on filtering arrays with predicates if you need to validate collections of mementos in storage.
7) Persistence and Serialization Strategies
If you persist mementos to disk or over the network, decide on a serialization strategy. For simple JSON-safe snapshots, JSON.stringify is fine. For complex data (Dates, Maps, Sets), provide custom serializers/deserializers or use libraries like superjson.
Pattern for custom serialization hooks:
interface SerializableMemento<T> extends Memento<T> {
serialize(): string
}
function withSerialization<T>(m: Memento<T>, serializeFn: (snap: T) => string): SerializableMemento<T> {
return { ...m, serialize: () => serializeFn(m.snapshot) }
}Generic serialization also pairs with caching and memoization strategies discussed later; check our guides on memoization and cache mechanisms for options to store precomputed diffs or snapshot hashes.
8) Performance Optimization: Structural Sharing & Diffs
To reduce memory overhead for many mementos, store diffs or use structural sharing. Libraries like Immer allow producing immutable snapshots with minimal copying.
Simple diff example (shallow):
function shallowDiff<T extends object>(a: T, b: T) {
const changes: Partial<T> = {}
for (const k of Object.keys(b) as Array<keyof T>) {
if (a[k] !== b[k]) changes[k] = b[k]
}
return changes
}Store base snapshot + diffs. Reconstruct by applying diffs in order. This pattern reduces memory but increases restore CPU. For caching repeated snapshots, try memoization techniques from our memoization functions guide.
9) Integrating with Observer or Event Systems
When state changes, notify subscribers. A typed memento can serve as the payload in observer patterns for UI and persistence.
type Subscriber<T> = (m: Memento<T>) => void
class ObservableState<T> {
private subs: Subscriber<T>[] = []
private state: T
constructor(initial: T) { this.state = initial }
subscribe(s: Subscriber<T>) { this.subs.push(s); return () => { this.subs = this.subs.filter(x => x !== s) } }
commit(m: Memento<T>) { this.state = m.snapshot; this.subs.forEach(s => s(m)) }
}If you need to build memory-safe subscriptions with typed events and async streams, see our piece on the Observer pattern for patterns on leak prevention and async handling.
10) Composable Builders for Complex Mementos
When snapshots are complex (many optional fields, nested builders), use a typed builder to compose snapshots before committing them. This keeps creation logic readable and type-safe.
class StateBuilder {
private payload: Partial<State> = {}
setCursor(c: number) { this.payload.cursor = c; return this }
setItems(items: string[]) { this.payload.items = items; return this }
build(initial: State) { return createMemento({ ...initial, ...(this.payload as Partial<State>) }) }
}If your app needs fluent builders and enforced invariants, review typed Builder pattern implementations to combine compile-time guarantees with runtime validations.
Advanced Techniques
For high-performance or collaborative applications, consider these expert strategies:
- Structural sharing with persistent data structures: Use libraries like Immutable.js or Immer to cheaply produce snapshots and keep shared references between versions.
- Snapshot compression and hashing: Store hashes to deduplicate identical snapshots and compress before persistence.
- Lazy restoration with proxies: Keep snapshots as deltas and apply them lazily on access, using Proxy to materialize fields on demand.
- Concurrency and optimistic updates: Tag mementos with version vectors or timestamps and handle merges, similar to CRDT approaches.
- Typed diffs: Model diffs as types (Diff
) so application code can reason about change sets and apply them safely.
Combining memoization and caching with mementos reduces reconstruction cost. See our practical guides on caching mechanisms and memoization for idioms.
Best Practices & Common Pitfalls
Dos:
- Do define Memento
to preserve types. - Do choose copy strategy that matches your data (structuredClone for complex objects, JSON for simple data).
- Do use assertion functions to validate restored snapshots to avoid corrupt states.
- Do integrate undo/redo with Command or transactional semantics to keep operations atomic.
Don'ts:
- Don't assume JSON.stringify covers all cases—Dates, Maps, Sets, and class instances are broken.
- Don't keep unbounded undo stacks in memory—use a ring buffer, limit size, or offload to disk.
- Don't mutate snapshots after creating them. Treat them as immutable values to prevent subtle bugs.
Troubleshooting tips:
- If restore fails due to missing methods, consider serializable representations and rehydrate class behavior during deserialize.
- If memory usage spikes, switch to diffs or compress snapshots.
- For inconsistent restores in concurrent scenarios, add version checks and merge strategies.
Real-World Applications
Memento pattern shows up in editors (undo/redo), form wizards (checkpointing), games (save/load), data pipelines (checkpointing processing state), and collaborative apps (local snapshots for conflict resolution). For example, an editor might capture document model snapshots when a transaction completes and use an UndoManager to offer user-level undo. In a React app, snapshots can be used to rollback state in complex forms or to implement time-travel debugging.
You can combine Memento with the State pattern for complex state machines where mementos capture partial or entire machine state for safe transitions.
Conclusion & Next Steps
Typing the Memento pattern in TypeScript helps prevent bugs and makes your snapshots safer to serialize, persist, and restore. Start with a simple generic Memento
Recommended next steps: read our guides on the Command pattern, Builder pattern, and Using assertion functions to strengthen your implementation.
Enhanced FAQ
Q: When should I use shallow snapshots vs deep snapshots? A: Use shallow snapshots when your state's nested objects are immutable or when references are acceptable. Use deep snapshots when nested mutation would cause restored states to diverge from the intended snapshot. If performance is a concern, prefer copy-on-write or structural sharing.
Q: How do I handle class instances with methods when serializing mementos? A: Serialize the instance data (fields) and store a type tag. On deserialization, create a new instance and rehydrate fields or use factory functions. Avoid serializing methods directly—recreate behavior during rehydration.
Q: Are there libraries that make snapshots easier? A: Yes. Immer provides immutable snapshots with minimal copy costs. Immutable.js, mori, and persistent data structure libraries provide structural sharing. For serialization, superjson helps with Dates and Maps.
Q: How can I limit memory growth in an undo/redo manager? A: Cap stack sizes, use diffs instead of full snapshots, compress snapshots, or persist older states to disk. A ring buffer is simple: when capacity is reached, drop the oldest mementos.
Q: What about concurrency and collaborative editing? A: Add version vectors, CRDTs, or operational transforms. Mementos alone are not a synchronization mechanism; they are local snapshots. For conflict resolution, combine mementos with merge strategies or build CRDT-aware diffs.
Q: How do assertion functions improve safety with mementos? A: Assertion functions let you validate incoming data at runtime and narrow types for the compiler using the "asserts" syntax. This ensures code that restores snapshots relies on validated shapes rather than unchecked any types. See our guide on using assertion functions for patterns.
Q: Should mementos be immutable? Why? A: Yes—treat snapshots as immutable to avoid accidental mutations that affect other references or future restores. Immutable mementos make reasoning about history easier and avoid subtle bugs when the same snapshot is referenced in multiple parts of the app.
Q: Can I combine memoization with mementos to speed up restores? A: Yes. Cache computed reconstructions for repeated restores of the same snapshot. Use stable hashing or structural sharing to detect identical snapshots. Refer to our guide on memoization functions to design cache keys and memo strategies.
Q: How should I test Memento implementations? A: Unit-test capture/restore invariants, memory behavior under many snapshots, and edge cases like invalid serialized input. Use property-based tests to verify that applying diffs and reconstructing produces expected results. Consider integration tests for undo/redo flows.
Q: What patterns mesh well with Memento? A: Command for encapsulating undoable operations, State for managing machine transitions, Builder for complex snapshot construction, and Observer for notifying interested parties. See our linked guides on Command, State, and Builder for deeper integrations.
Q: Any pitfalls I should watch for when designing typed mementos? A: Avoid leaking implementation details of the origin object. Provide stable APIs to create and restore mementos and prefer serialization hooks for non-JSON-safe types. Always consider memory growth and use guards for deserialization. When in doubt, prefer explicit designs with small, well-documented snapshot shapes.
If you want hands-on patterns for typed modules and encapsulation when implementing mementos in larger systems, check our guide on Typing Module Pattern Implementations in TypeScript — Practical Guide and for performance-focused patterns see Typing Cache Mechanisms: A Practical TypeScript Guide. For event-driven integrations and subscription safety, our Observer pattern write-up contains practical tips.
Happy coding—capture safely, restore reliably, and keep your types intact.
