Skip to content

Command: requests as objects for undo, redo and queued jobs

Store the request, not the call: what is stored can be undone, redone, logged and queued.

Predict first

Before you read: predict

ExamHall's essay editor keeps each edit as a command in a history with undo and redo. A candidate types, undoes, types again, then presses redo:

What does main.ts print?

Code for this question
// history.tsexport class Doc { text = ''; }export interface Command { apply(doc: Doc): void; revert(doc: Doc): void }export class Insert implements Command {  constructor(private at: number, private chunk: string) {}  apply(doc: Doc) { doc.text = doc.text.slice(0, this.at) + this.chunk + doc.text.slice(this.at); }  revert(doc: Doc) { doc.text = doc.text.slice(0, this.at) + doc.text.slice(this.at + this.chunk.length); }}export class History {  private done: Command[] = [];  private undone: Command[] = [];  constructor(private doc: Doc) {}  run(c: Command) { c.apply(this.doc); this.done.push(c); }  undo() { const c = this.done.pop(); if (c) { c.revert(this.doc); this.undone.push(c); } }  redo() { const c = this.undone.pop(); if (c) { c.apply(this.doc); this.done.push(c); } }} // main.tsimport { Doc, History, Insert } from './history';const doc = new Doc();const history = new History(doc);history.run(new Insert(0, 'Supply'));history.run(new Insert(6, ' rises'));history.undo();history.run(new Insert(6, ' falls'));history.redo();console.log(doc.text);
Pick one

The 30-second version

A command is a request stored as an object: the action and its arguments. An invoker runs it and keeps it, so it can be undone, redone, logged or queued. Undo uses what the command recorded as it ran; redo runs it again with the same arguments, so a new command must empty the redo stack.

Store the request, not the call: what is stored can be undone, redone, logged and queued.

How it works

Step 1 of 11

A request as an object. A command holds one action and its arguments, such as an insert with its position and text.
In the kitchen
  • each treatment is one entry on the record: what, where, with which solvent → a command holds the action and its arguments (step 1)

Analogy: A paintings conservator's treatment record (art conservation)

In the studio In code
each treatment is one entry on the record: what, where, with which solvent a command holds the action and its arguments (step 1)
the entry keeps what came off, so the treatment can be reversed undo needs what changed, read as it runs (step 3)
a cleaning abandoned before the solvent touched the paint is crossed out the history holds what ran (step 5)
after reversing a retouch she paints a different one there; the old entry is closed, written for a surface that is gone a new command empties redo (step 6)
a colleague in another studio carries on from the record alone a queued job is a serialised command (step 10)
"do what we agreed on the phone" is not an entry a closure is not data (step 9)

In real terms: an action written down as data can be handed on, kept and reversed; one that exists only as the doing cannot.

Breaks down when: a conservator can only aim at reversibility, while code can restore a stored value exactly.

Contrasting case: a one-off button click with nothing to undo, log or queue: call the function directly.

Examples, run for real

4.1 ExamHall EXH-4850 and MR !5150: the essay editor's redo

EXH-4850: a candidate typed " rises", undid it, typed " falls" and pressed redo, and her essay changed under her. The history as shipped runs commands like this:

// history.ts (as shipped)
run(c: Command) { c.apply(this.doc); this.done.push(c); }

MR !5150 adds one statement: this.undone = []; at the end of run.

4.1 EXH-4850 as shipped: "Supply" → "Supply rises" → "Supply" → "Supply rises" → "Supply" → "Supply falls" → "Supply rises falls" → "Supply falls" → "Supply"
4.1 MR !5150 (this.undone = [] in run): "Supply" → "Supply rises" → "Supply" → "Supply rises" → "Supply" → "Supply falls" → "Supply falls" → "Supply" → ""

As shipped, redo applied the stale insert at position 6 (steps 6–7).

4.2 The grade-exam job as a serialised command

ExamHall queues { name: 'grade-exam', data: { attemptId: 'att-7' } }; the worker looks the name up in its handler map. The script sends each job through JSON.stringify and JSON.parse, as BullMQ's docs say it stores job data, then delivers it twice, a labelled model of one retry:

4.2 data job after JSON: {"name":"grade-exam","data":{"attemptId":"att-7"}} · handler for "grade-exam" found true
4.2 closure job after JSON: {"name":"grade-exam"} · run is undefined
4.2 data job delivered twice (model of one retry): appending handler wrote 2 grades · handler keyed by attemptId wrote 1

The data job reached its handler and graded the attempt; the closure job lost its function in the round trip (step 9). Delivered twice, the handler that appends wrote two grades and the one keyed by attemptId wrote one: why the hook asks for an idempotent handler, which async-idempotent-consumers teaches.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A box office's agent console holds seats through commands. Seat C7 is already held by another buyer when Ana's agent tries it:

What does main.ts print?

Code for this question
// seats.tsexport type Seats = Map<string, string>;export interface Command { execute(): void; undo(): void }export class Hold implements Command {  constructor(private seats: Seats, private seat: string, private buyer: string) {}  execute() {    const holder = this.seats.get(this.seat);    if (holder) throw new Error(`${this.seat} is held by ${holder}`);    this.seats.set(this.seat, this.buyer);  }  undo() { this.seats.delete(this.seat); }}export class Console {  private done: Command[] = [];  run(c: Command) { this.done.push(c); c.execute(); }  undo() { this.done.pop()?.undo(); }} // main.tsimport { Seats, Hold, Console } from './seats';const seats: Seats = new Map([['C7', 'bea']]);const agent = new Console();agent.run(new Hold(seats, 'C5', 'ana'));try { agent.run(new Hold(seats, 'C7', 'ana')); } catch { /* the screen says "C7 taken" */ }agent.undo();console.log(`C5 ${seats.get('C5') ?? 'free'} · C7 ${seats.get('C7') ?? 'free'}`);
Pick one

Question 2 of 7

A ledger's risk screen builds the day's credit-limit changes as commands, runs them as one batch, then undoes the last:

What does main.ts print?

Code for this question
// limits.tsexport type Limits = Map<string, number>;export class SetLimit {  private before: number;  constructor(private limits: Limits, private account: string, private next: number) {    this.before = limits.get(account) ?? 0;  }  execute() { this.limits.set(this.account, this.next); }  undo() { this.limits.set(this.account, this.before); }}export class Batch {  private done: SetLimit[] = [];  runAll(cs: SetLimit[]) { for (const c of cs) { c.execute(); this.done.push(c); } }  undo() { this.done.pop()?.undo(); }} // main.tsimport { Limits, SetLimit, Batch } from './limits';const limits: Limits = new Map([['acc-4', 500]]);const batch = new Batch();batch.runAll([new SetLimit(limits, 'acc-4', 800), new SetLimit(limits, 'acc-4', 1000)]);batch.undo();console.log(`acc-4 limit ${limits.get('acc-4')}`);
Pick one

Question 3 of 7

A video player keeps its state in a Redux store. An action helper copied from another app, which uses a middleware, is dispatched on this store:

With redux 5.0.1, what does main.ts print?

Code for this question
// player.tsimport { legacy_createStore as createStore } from 'redux';export interface PlayerState { at: number; playing: boolean }type PlayerAction = { type: 'seek'; to: number } | { type: 'pause' };function player(state: PlayerState = { at: 0, playing: true }, action: PlayerAction): PlayerState {  switch (action.type) {    case 'seek': return { ...state, at: action.to };    case 'pause': return { ...state, playing: false };    default: return state;  }}export const store = createStore(player); // actions.tsexport function pauseAfterAd(): any {  return (dispatch: (a: { type: 'pause' }) => void) => dispatch({ type: 'pause' });} // main.tsimport { store } from './player';import { pauseAfterAd } from './actions';const log: string[] = [];store.subscribe(() => { const s = store.getState(); log.push(`${s.at}s ${s.playing ? 'playing' : 'paused'}`); });store.dispatch({ type: 'seek', to: 90 });try { store.dispatch(pauseAfterAd()); } catch { /* the player shows an error banner */ }store.dispatch({ type: 'seek', to: 120 });console.log(log.join(' · '));
Pick one

Question 4 of 7

A chat room's history covers sending and pinning. A moderator sends a notice, pins it, undoes both, then redoes both:

What does main.ts print?

Code for this question
// room.tsexport interface Msg { id: string; text: string }export class Room {  messages: Msg[] = [];  pinned: string | null = null;  private seq = 0;  nextId() { this.seq += 1; return `m${this.seq}`; }}export interface Command { execute(): void; undo(): void }export class Send implements Command {  id = '';  constructor(private room: Room, private text: string) {}  execute() { this.id = this.room.nextId(); this.room.messages.push({ id: this.id, text: this.text }); }  undo() { this.room.messages = this.room.messages.filter((m) => m.id !== this.id); }}export class Pin implements Command {  private before: string | null = null;  constructor(private room: Room, private target: string) {}  execute() {    this.before = this.room.pinned;    this.room.pinned = this.room.messages.some((m) => m.id === this.target) ? this.target : null;  }  undo() { this.room.pinned = this.before; }}export class History {  private done: Command[] = [];  private undone: Command[] = [];  run(c: Command) { c.execute(); this.done.push(c); this.undone = []; }  undo() { const c = this.done.pop(); if (c) { c.undo(); this.undone.push(c); } }  redo() { const c = this.undone.pop(); if (c) { c.execute(); this.done.push(c); } }} // main.tsimport { Room, Send, Pin, History } from './room';const room = new Room();const history = new History();const notice = new Send(room, 'standup moves to 10:30');history.run(notice);history.run(new Pin(room, notice.id));history.undo();history.undo();history.redo();history.redo();console.log(`messages ${room.messages.map((m) => m.id).join(', ')} · pinned ${room.pinned ?? 'none'}`);
Pick one

Question 5 of 7

The interviewer says: "Implement undo and redo for ExamHall's essay editor. Write createEditor() returning text(), insert(at, chunk), erase(at, length), undo(), redo() and log(), and a replay(records) that applies a log to a new editor." Every edit is a command kept as plain data. undo() takes back the last edit and redo() re-applies the last undone one; undoing an erase brings the erased text back; a new edit leaves nothing to redo; undo() and redo() with nothing to do change nothing; log() returns the edits in effect, as records that survive JSON.stringify and JSON.parse. You have 20 minutes and a plain editor.

Say your test list first, then write it.

Question 6 of 7

A video channel keeps every playlist edit as a plain record, so a studio mirror can replay an editor's records. The mirror already holds an intro clip when it replays them:

What does main.ts print?

Code for this question
// playlist.tslet made = 0;const newId = () => `c${++made}`;export type Clip = { id: string; title: string };export type Rec = { type: 'add'; title: string; id?: string } | { type: 'rename'; id: string; title: string };export class Playlist {  clips: Clip[] = [];  private log: Rec[] = [];  run(r: Rec) { this.apply(r); this.log.push(r); }  apply(r: Rec) {    if (r.type === 'add') this.clips.push({ id: r.id ?? newId(), title: r.title });    else for (const c of this.clips) if (c.id === r.id) c.title = r.title;  }  records(): Rec[] { return JSON.parse(JSON.stringify(this.log)); }} // main.tsimport { Playlist } from './playlist';const mirror = new Playlist();mirror.run({ type: 'add', title: 'intro' });const editor = new Playlist();editor.run({ type: 'add', title: 'trailer' });editor.run({ type: 'add', title: 'interview' });editor.run({ type: 'rename', id: editor.clips[0].id, title: 'teaser' });for (const r of editor.records()) mirror.apply(r);console.log(`mirror ${mirror.clips.map((c) => c.title).join(', ')}`);
Pick one

Question 7 of 7

Two box-office agents share one seat map. Ana's console keeps her own history; Ben holds seats from his console on the same map:

After Ana's undo, main.ts must print A1 ana · A2 free · B5 ben.

How should AnaHistory (in ana-history.ts) undo?

Code for this question
// seats.tsexport type Seats = Map<string, string>;export class Hold { constructor(readonly seat: string, readonly agent: string) {} } // main.tsimport { Seats, Hold } from './seats';import { AnaHistory } from './ana-history';const seats: Seats = new Map();const ana = new AnaHistory(seats);ana.run(new Hold('A1', 'ana'));ana.run(new Hold('A2', 'ana'));seats.set('B5', 'ben');ana.undo();console.log(['A1', 'A2', 'B5'].map((s) => `${s} ${seats.get(s) ?? 'free'}`).join(' · '));
Pick one

Go deeper

Pro tips
  1. Merge a run of keystrokes into one command. Typing a word as one insert, not one per letter, makes undo take back the word. When: an editor whose undo should feel like a word processor's. Verify (RAN, tips.mjs): the first line below.
  2. Cap the undo stack. Drop the oldest command once the history holds a set number, so a long session keeps bounded memory. When: an editor that can stay open for hours. Verify (RAN, tips.mjs): the second line below.
tip 1 12 keystrokes: one command each, one undo leaves "Supply rise" · merged per word (2 commands), one undo leaves "Supply"
tip 2 1000 edits with a cap of 100: the history holds 100 · after 100 undos the text keeps 900 characters · one more undo keeps 900
Source

Every graded behaviour is executed, so no item is keyed on these pages; they back the teaching. Valid as of 2026-10-17.

  • BullMQ, Job Data (under the row's "BullMQ docs: Jobs"), https://docs.bullmq.io/guide/jobs/job-data (bullmq-job-data.txt). "Job data is sent through Redis as a string, so before being stored it is serialized with JSON.stringify and on the worker side it is deserialized with JSON.parse." and "The payload must be JSON-serializable. Values like undefined, functions, BigInt, Map/Set, circular references and similar are not preserved.".
  • BullMQ, Jobs (the row's second source), https://docs.bullmq.io/guide/jobs (bullmq-jobs.txt). "Queues can hold different types of jobs, which determine how and when they are processed." (step 10).
  • BullMQ, Retrying failing jobs and Idempotent jobs, https://docs.bullmq.io/guide/retrying-failing-jobs (bullmq-retrying.txt) and https://docs.bullmq.io/patterns/idempotent-jobs (bullmq-idempotent.txt). "In order to activate automatic job retries you should use the attempts setting with a value larger than 1" and "it should not make a difference to the final state of the system if a job successfully completes on its first attempt, or if it fails initially and succeeds when retried."
  • Redux, Redux Fundamentals, Part 2 (not named by the row; teaching only), https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow (redux-fundamentals-2.txt). "An action is a plain JavaScript object that has a type field." and "Since actions are plain JS objects, they can be logged, serialized, stored, and later replayed for debugging or testing purposes." (step 11).
  • Redux, Style Guide (not named by the row; teaching only), https://redux.js.org/style-guide/ (redux-style-guide.txt). "Exception: you may put non-serializable values in actions if the action will be intercepted and stopped by a middleware before it reaches the reducers." (step 11).
  • refactoring.guru, Command (not named by the row; teaching only), https://refactoring.guru/design-patterns/command (refactoring-guru-command.txt). "Command is a behavioral design pattern that turns a request into a stand-alone object that contains all information about the request." (step 1).
  • The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book that cannot be fetched as text, and keys nothing.

Checked against the tools and docs as of 2026-10-17.