← Design patterns
Behavior Checkpoints and restorable state

Memento

Keep a way back.

A public-library member prefers the East Branch, print format, and new-arrival alerts for history and maps. They try a different setup, then decide to return to those saved preferences. Which part of the member record should remember the checkpoint?

TypeScriptGoRust The same checkpoint in each language.

Definition

A memento captures an object’s internal state without breaking its encapsulation, so that the object can be put back in that state later.

After Gamma et al., Design Patterns (1994), Memento

01Keep a way back

The object that owns the state should know how to restore it.

A simple preference form can keep the two old values and put them back on Cancel. That is a reasonable solution while those fields are all it needs to restore.

cancel.ts
// The settings form keeps the old values itself and puts them back on Cancel.
// (A fragment: member is the member record the next section builds.)
const before = { branch: member.prefs.branch, format: member.prefs.format };
member.edit('Riverside Branch', 'digital');

// ...the member presses Cancel:
member.edit(before.branch, before.format);

Now the member can change branch, format and the subjects they follow together, and save a setup to come back to later. Their loans may change while they experiment. A separate history panel that copies the member record would need to know which fields are preferences and which must stay current, and how to copy a list so that later edits cannot reach the saved one.

Memento lets an object capture restorable state and recover it later, while another object keeps the checkpoint without depending on the saved representation. The member record decides what belongs in the snapshot. A caretaker keeps its opaque token and returns it when asked. The record performs the restoration.

The traditional names describe those jobs: the member record is the originator, the saved state is the memento, and the history holder is the caretaker. The caretaker stores an opaque token it cannot read, with a label the member can recognize. A token belongs to the record that created it.

02Let the owner remember

The history keeps the token. The record knows the contents.

The record captures its preferences into a token and keeps what the token stands for. The history files tokens under labels. Restoring hands a token back, and the record checks it is its own and puts the saved preferences back. This is the whole program the lab runs: the record, the history, and a member who experiments twice and returns to the same checkpoint each time.

member.ts
type Preferences = { branch: string; format: string; alerts: string[] };

// The memento: an empty token. Only the record that made it can read it.
type Checkpoint = Readonly<object>;

const copy = (p: Preferences): Preferences => ({ ...p, alerts: [...p.alerts] });

// The originator: it owns the preferences and decides what a checkpoint holds.
class MemberRecord {
  prefs: Preferences = {
    branch: 'East Branch',
    format: 'print',
    alerts: ['history', 'maps'],
  };
  loans = 2; // live: never captured, never restored
  private saved = new WeakMap<Checkpoint, Preferences>();

  capture(): Checkpoint {
    const token: Checkpoint = Object.freeze({});
    this.saved.set(token, copy(this.prefs));
    return token;
  }

  restore(token: Checkpoint): void {
    const saved = this.saved.get(token);
    if (!saved) throw new Error('foreign checkpoint');
    this.prefs = copy(saved);
  }

  edit(branch: string, format: string): void {
    this.prefs.branch = branch;
    this.prefs.format = format;
  }

  follow(subject: string): void {
    this.prefs.alerts.push(subject);
  }

  unfollow(subject: string): void {
    const at = this.prefs.alerts.indexOf(subject);
    if (at >= 0) this.prefs.alerts.splice(at, 1);
  }
}

// The caretaker: it keeps labelled tokens and never looks inside one.
class CheckpointHistory {
  private entries: { label: string; token: Checkpoint }[] = [];
  private member: MemberRecord;

  constructor(member: MemberRecord) {
    this.member = member;
  }

  save(label: string): void {
    this.entries.push({ label, token: this.member.capture() });
  }

  restore(label: string): void {
    const entry = this.entries.find((e) => e.label === label);
    if (!entry) throw new Error('no checkpoint');
    this.member.restore(entry.token);
  }
}

function summary(member: MemberRecord): string {
  const { branch, format, alerts } = member.prefs;
  const subjects = JSON.stringify(alerts);
  return `${branch}, ${format}, ${subjects}, loans ${member.loans}`;
}

const member = new MemberRecord();
const checkpoints = new CheckpointHistory(member);
checkpoints.save('My usual settings');

// The first experiment, then back. A book is checked out meanwhile.
member.edit('Riverside Branch', 'digital');
member.unfollow('history');
member.loans = 3;
checkpoints.restore('My usual settings');
const first = summary(member);

// The second experiment, then back to the same checkpoint.
member.edit('Harbor Branch', 'large print');
member.unfollow('history');
member.follow('poetry');
checkpoints.restore('My usual settings');
const second = summary(member);

console.log(`first:  ${first}`);
console.log(`second: ${second}`);
Who owns what
RoleResponsibilityWhat it handles
Originator: MemberRecordKnows the stateDecides what a checkpoint holds, copies it in and out, and checks that a token is its own.
Memento: CheckpointHolds a saved versionAn empty frozen token; the record’s WeakMap holds what it stands for.
Caretaker: CheckpointHistoryDecides when, and how manyLabels and keeps tokens, and hands one back when asked. It never looks inside.
Live state: loansStays currentNever captured, never restored.

The caretaker chooses retention and ordering. It can display “My usual settings” without knowing how preferences are represented. Add a new preference later and the record’s state, capture rule, restore rule and tests change together. The caretaker continues to hold the same opaque token type.

03Restore it twice

A retained checkpoint must outlive a restore.

Experiment

One checkpoint, two restores

member.ts
// Line 27, in restore, installs the saved preferences instead of a copy:
this.prefs = saved;
Suppose restore installed the saved preferences instead of a copy, as above. The first restore still brings back East Branch. What does second show?
What a production memento addsValidation, derived output and a bounded history

The same record, checked. Validation belongs to the record and finishes before it changes live preferences: an empty branch, an unknown format or an empty subject is an error and changes nothing. Capture does not change current preferences or derived output. Restore validates the token’s owner before changing anything; two records with the same member ID still refuse each other’s tokens. A successful restore clears the recommendation preview, and so does every edit and every checkout.

History keeps a configured number of checkpoints, oldest first; its bound counts entries, not bytes. Save appends a checkpoint and releases the oldest entry when the limit is reached. Restore neither consumes that checkpoint nor deletes later ones. Clear releases the caretaker’s collection without changing the member record. This is named restoration, not a linear undo and redo history.

member.ts
export const formats = ['print', 'digital', 'large print'] as const;
export type Format = (typeof formats)[number];
export type Preferences = { branch: string; format: Format; alerts: string[] };

export type MemberView = Readonly<{
  memberId: string;
  prefs: Preferences;
  loans: number;
  preview: string | null;
}>;

// The memento: an empty, frozen token. The brand exists only for the type
// checker, so a plain {} is not a Checkpoint; the record's WeakMap holds what
// the token stands for.
declare const checkpointBrand: unique symbol;
export type Checkpoint = { readonly [checkpointBrand]: true };

export type MemberErrorCode =
  | 'empty branch'
  | 'unknown format'
  | 'empty subject'
  | 'foreign checkpoint'
  | 'no checkpoint'
  | 'empty label'
  | 'invalid limit';

export class MemberError extends Error {
  readonly code: MemberErrorCode;
  constructor(code: MemberErrorCode) {
    super(code);
    this.code = code;
  }
}

const copy = (p: Preferences): Preferences => ({ ...p, alerts: [...p.alerts] });

const isFormat = (value: string): value is Format =>
  (formats as readonly string[]).includes(value);

export class MemberRecord {
  readonly memberId: string;
  #prefs: Preferences;
  #loans: number;
  #preview: string | null = null; // derived: rebuilt from prefs and loans
  readonly #saved = new WeakMap<Checkpoint, Preferences>();

  constructor(memberId: string, prefs: Preferences, loans = 0) {
    this.memberId = memberId;
    this.#prefs = copy(prefs);
    this.#loans = loans;
  }

  capture(): Checkpoint {
    const token = Object.freeze({}) as Checkpoint;
    this.#saved.set(token, copy(this.#prefs));
    return token;
  }

  // Restores the saved preferences; loans stay current. The checkpoint stays
  // usable: restoring it again returns the same values.
  restore(token: Checkpoint): void {
    const saved = this.#saved.get(token);
    if (!saved) throw new MemberError('foreign checkpoint');
    this.#prefs = copy(saved);
    this.#preview = null;
  }

  edit(branch: string, format: string): void {
    if (!branch.trim()) throw new MemberError('empty branch');
    if (!isFormat(format)) throw new MemberError('unknown format');
    this.#prefs.branch = branch;
    this.#prefs.format = format;
    this.#preview = null;
  }

  follow(subject: string): void {
    if (!subject.trim()) throw new MemberError('empty subject');
    if (this.#prefs.alerts.includes(subject)) return;
    this.#prefs.alerts.push(subject);
    this.#preview = null;
  }

  unfollow(subject: string): void {
    const at = this.#prefs.alerts.indexOf(subject);
    if (at < 0) return;
    this.#prefs.alerts.splice(at, 1);
    this.#preview = null;
  }

  checkOut(): void {
    this.#loans += 1;
    this.#preview = null;
  }

  buildPreview(): void {
    const { branch, format, alerts } = this.#prefs;
    const subjects = alerts.join(', ') || 'no alerts';
    this.#preview = `${branch}: ${format}, ${subjects} (${this.#loans} loans)`;
  }

  view(): MemberView {
    return {
      memberId: this.memberId,
      prefs: copy(this.#prefs),
      loans: this.#loans,
      preview: this.#preview,
    };
  }
}

// The caretaker: labelled tokens, oldest first, at most `limit` of them.
export class CheckpointHistory {
  readonly #member: MemberRecord;
  readonly #limit: number;
  #entries: { label: string; token: Checkpoint }[] = [];

  constructor(member: MemberRecord, limit = 3) {
    if (!Number.isInteger(limit) || limit < 1 || limit > 5)
      throw new MemberError('invalid limit');
    this.#member = member;
    this.#limit = limit;
  }

  save(label: string): void {
    if (!label.trim()) throw new MemberError('empty label');
    this.#entries.push({ label, token: this.#member.capture() });
    if (this.#entries.length > this.#limit) this.#entries.shift();
  }

  // The newest checkpoint with this label. It stays in the history.
  restore(label: string): void {
    const newest = [...this.#entries].reverse();
    const entry = newest.find((e) => e.label === label);
    if (!entry) throw new MemberError('no checkpoint');
    this.#member.restore(entry.token);
  }

  labels(): string[] {
    return this.#entries.map((e) => e.label);
  }

  clear(): void {
    this.#entries = [];
  }
}

The brand exists only for the type checker, so a plain {} is not a Checkpoint; the #saved WeakMap is the runtime check. Errors carry a code a caller can switch on, and view() returns a copy, so a caller cannot edit the record through what it was shown.

Reading the TypeScriptAn empty token and a private WeakMap

Each record has its own WeakMap from tokens to saved preferences. Capture returns a frozen empty object and files a copy under it; membership in this particular map is the owner check, so another record’s token is simply not found. The token exposes no saved field. private is checked only by the compiler; the WeakMap is what keeps the saved values out of reach at run time, and the production version uses a #saved field too.

copy spreads the object and copies the array: a spread copies one level, so every nested mutable value needs its own copy. A WeakMap does not keep its keys alive, but the history’s entries do retain their tokens; removing an entry can make its saved values collectible if nothing else holds the token. A token cannot be serialized to recover what it stands for.

04Meet it in your language

You already save state and put it back.

The canvas 2D context is a memento you call every day. save() pushes the drawing state (transform, clipping region, fill and stroke styles, line width, font) onto a stack the context keeps, and restore() pops it. The context is originator and caretaker at once. The pixels you drew are not part of that state, and neither is the current path, so restoring never erases anything. The bug is a way out of the function that skips the restore: an early return or a throw leaves the translation and the fill in place for whoever draws next.

canvas.ts
// Before: the early return skips restore(), so the context keeps the
// translation and the red fill, and the next thing drawn on it moves too.
function drawBadgeBefore(
  ctx: CanvasRenderingContext2D,
  label: string,
  x: number,
  y: number,
) {
  ctx.save();
  ctx.translate(x, y);
  ctx.fillStyle = '#b3261e';
  if (!label) return;
  ctx.fillText(label, 0, 0);
  ctx.restore();
}

// After: restore() runs on every path out of the function.
function drawBadge(
  ctx: CanvasRenderingContext2D,
  label: string,
  x: number,
  y: number,
) {
  ctx.save();
  try {
    ctx.translate(x, y);
    ctx.fillStyle = '#b3261e';
    if (!label) return;
    ctx.fillText(label, 0, 0);
  } finally {
    ctx.restore();
  }
}

Keep each save() and its restore() in one function, balanced on every path. To take back a drawing, keep the data that produced it and redraw: the context’s checkpoint covers how to draw, not what was drawn.

05Choose what history costs

Choose the representation around the work you need to preserve.

Memento helps when an owner must restore a coherent group of internal values without asking the caretaker to rebuild them. Keep a direct draft or a few old scalar values when those already express the whole requirement clearly. Start with what a restore promises:

What restoration promises for member preferences
StateOn captureOn restore
Branch, format, alertsKeep an independent saved version.Make a fresh editable copy of those preferences.
The record itselfBind the token to this record.Restore into that record only; refuse another’s token.
LoansLeave them outside the checkpoint.Keep their current values.
Recommendation previewDo not capture derived output.Clear it; a later step rebuilds it from the restored preferences and current loans.
What would you have to change tomorrow?
The changeA panel that copies the fieldsA memento
A new preference arrives (a pickup locker)Every panel that saves preferences learns to copy it.The record’s capture and restore change; the panels keep the same token.
Loans change during the experimentEach panel must know not to copy them back.They are never in a checkpoint.
A saved setup must survive a reloadPlain values serialize as they are.A runtime token does not; it needs a schema and a version.
The one edit has an exact inverse (a toggle)Undoing the toggle is smaller than any copy.A full checkpoint copies more than the edit changed.

This example copies the selected preferences at capture and restore. Work grows with the saved state, while the caretaker’s count limit bounds how many tokens it holds, not their total bytes. For a large state or frequent checkpoints, consider immutable versions with structural sharing, grouped changes, deltas, or periodic snapshots. Measure the workload you actually have.

Inverse operations can be smaller and more precise when an edit has a reliable inverse. A full checkpoint is convenient when many internal fields change together. Commands can retain mementos for their undo behaviour, but Memento alone does not decide gesture grouping, redo branching, or how to merge concurrent edits. Immutable state is the other way to the same promise: an old version that later edits cannot reach, protected by immutability rather than by an opaque token, as CodeMirror’s editor states are.

Opacity is an interface decisionMetadata, immutable values, and security

Useful checkpoint metadata can be public: a label, creation time, or a version identifier. That does not require exposing every restorable field. The boundary matters when it keeps consumers from depending on the record’s internal schema.

A publicly readable immutable value can also be a good snapshot. Encapsulation limits accidental coupling; whether a member may change preferences is a separate authorization check.

Where local restoration stopsPersistence, side effects, and session lifetime

A checkpoint here is valid only for the record instance that created it. Reopening the same member’s settings creates a new owner. Long-lived checkpoints need an explicit serialized schema, version compatibility, validation, and migration rules; a runtime token is not a durable format.

A request already in flight keeps running after a restore; its completion may need a generation or version check before updating the view.

Clear history when the session ends. The retention count bounds only this caretaker’s entries; another caller can still retain a captured token. Releasing a reference does not guarantee reclamation, let alone secure erasure.

Its neighbours: Prototype creates another instance from existing state; this memento restores earlier preferences into the same record. Their copy mechanics overlap while their identity and lifecycle promises differ. Command represents an action, and can retain a memento captured before it ran when restoring state is a suitable way to reverse it. Stack supplies one possible history ordering; the record still owns what a checkpoint means.

06Make the call

Say who remembers, who keeps, and who restores.

A task board moves a card the moment you drag it and sends the move to the server; if the server refuses, the card goes back. You drag card A, then card B while A’s request is still pending. B’s request succeeds and A’s fails. What should the rollback restore?
A sign-up form has two fields and a Cancel button that puts back what they held when the form opened. Nothing else saves or restores them. What should Cancel use?

Before you open the answer below, try explaining the design without its name: who captures, who keeps, who restores, and what stays live. Then name one new field, a reading streak or a pickup locker, and decide which side of the boundary should own it.

One way to say itWho remembers, who keeps, and who restores

“The member record captures the member’s selected preferences. The history retains a labelled token it cannot read. When the member restores it, the record checks the token is its own and puts those preferences back, while loans stay current and the preview is rebuilt.” The record copies on both sides of the checkpoint, at capture so later edits cannot reach the saved version and at restore so edits after a restore cannot either; that is what lets one checkpoint be restored again and again. A pickup locker is a preference: it goes into capture and restore, and the history does not change. A reading streak is live, like loans: it never goes into a checkpoint. When one form has two values and nothing else keeps them, skip the pattern and keep the values.

Practice in the lab

A network console takes a checkpoint of a switch port before a maintenance change and rolls the port back when the change breaks something, as the lesson’s settings panel restores a member’s saved preferences. A port’s configuration (its VLAN and the VLANs it carries) is what comes back; its link state and traffic counters are live, as the member’s loans are. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • MEMENTO-101 (easy) A rollback rewinds the traffic counters
  • MEMENTO-102 (medium) Rolled-back ports keep addresses from the wrong VLAN Pro
  • MEMENTO-103 (hard) Review: checkpoints as a log of changes Pro