DEV Community

Cover image for The Command Pattern with Discriminated Unions: A 90-Line Undo/Redo
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Command Pattern with Discriminated Unions: A 90-Line Undo/Redo


You open a drawing app. Draw a line, move it, draw another, change the colour. The second one was wrong, so Cmd+Z, and it vanishes. Cmd+Z again, the colour reverts. Cmd+Shift+Z, the colour comes back. Every action is reversible, and the order is preserved.

The textbook solution to this is the Command pattern. The textbook implementation is interface ICommand { execute(): void; undo(): void; } and a class for every kind of action. A small drawing app ends up with thirty classes — AddLineCommand, DeleteLineCommand, MoveLineCommand, ChangeColourCommand, ResizeCommand, RotateCommand, each one a tiny constructor that captures the data the action will need to invert itself. Half the file is this.x = x; this.y = y;. The classes add nothing. They are noise around a data shape.

In TypeScript you do not need any of that. A command is a tagged record. Each variant carries the data needed to apply it and the data needed to invert it. A history stack, a redo stack, and two pure functions (apply(state, cmd) and inverse(cmd)) are the whole machine. Around 90 lines once coalescing is in. This post walks the whole thing end to end, including command coalescing for typing, and the line where you stop building it yourself and reach for Yjs or operational transform instead.

Why ICommand Is the Wrong Abstraction in TS

The OOP framing of the Command pattern, as it appears in the Gang of Four book and a thousand Java codebases, makes a class for every command because the language forces it. Java does not have closures over data; you store the data in fields. Java does not have sum types; you express variance through subclassing. Java does not have pattern matching; you call a virtual method.

TypeScript has all three. The pattern collapses.

The classic shape, transcribed faithfully:

interface ICommand {
  execute(state: State): State;
  undo(state: State): State;
}

class AddLineCommand implements ICommand {
  constructor(private readonly line: Line) {}
  execute(state: State): State {
    return { ...state, lines: [...state.lines, this.line] };
  }
  undo(state: State): State {
    return {
      ...state,
      lines: state.lines.filter((l) => l.id !== this.line.id),
    };
  }
}

class DeleteLineCommand implements ICommand {
  constructor(
    private readonly id: string,
    private readonly snapshot: Line,
  ) {}
  execute(state: State): State {
    return {
      ...state,
      lines: state.lines.filter((l) => l.id !== this.id),
    };
  }
  undo(state: State): State {
    return { ...state, lines: [...state.lines, this.snapshot] };
  }
}
Enter fullscreen mode Exit fullscreen mode

Two commands, twenty lines. Add eight more action types and the file is 100+ lines of constructor boilerplate. Worse, the behaviour is split across files. To audit how delete works, you read DeleteLineCommand.ts. To audit how undo works for delete, you also read DeleteLineCommand.ts. To compare delete and add, you open both. The reducer logic is fragmented across one class per case.

There is also a quieter problem. ICommand is structural in TypeScript — anything with execute and undo of the right shape satisfies it. So the union of "all commands" is open. You cannot ask tsc to prove your command bus handles every variant, because the type system does not know what every variant is. You wrote an interface, not a union.

The Discriminated-Union Rewrite

A command is data. The kind of command is one literal field. The fields the command needs are the rest of the record. Here is the same drawing app, every command type in one declaration:

type Id = string;
type Colour = `#${string}`;
type Point = { x: number; y: number };
type Line = { id: Id; from: Point; to: Point; colour: Colour };

type Command =
  | { kind: "addLine"; line: Line }
  | { kind: "deleteLine"; id: Id; snapshot: Line }
  | { kind: "moveLine"; id: Id; to: Point; prev: Point }
  | { kind: "setColour"; id: Id; colour: Colour; prev: Colour };
Enter fullscreen mode Exit fullscreen mode

Read it once: every command kind, and every field it carries, is on the page. The redundant-looking fields (snapshot on deleteLine, prev on moveLine and setColour) do the work the OOP version hid in constructor parameters. They capture what the state was before the command applied, so the inverse can reconstruct it without scanning history.

This is the heart of the design: each command is self-inverting. Given the command record, you can compute the state change in either direction with no help from anywhere else. No back-pointer to the previous state. No diff against a snapshot. Just the data the command captured at the moment it was issued.

The state shape is a flat record:

type State = {
  lines: Record<Id, Line>;
  texts: Record<Id, string>;
};
Enter fullscreen mode Exit fullscreen mode

A Record<Id, Line> (instead of Line[]) makes apply and inverse O(1) per operation instead of O(n). For an undo stack that gets thousands of entries deep (and it will, if the user keeps the app open all afternoon) the constant factor matters.

apply and inverse, Both Totally Typed

Two functions. apply(state, cmd) returns the next state given a command. inverse(cmd) returns the command that undoes it. Both are pure, both close over the discriminated union, and both end with assertNever, the seatbelt that fires the day someone adds a new command kind and forgets to update the reducer.

function assertNever(x: never): never {
  throw new Error(`unhandled command: ${JSON.stringify(x)}`);
}

function apply(state: State, cmd: Command): State {
  switch (cmd.kind) {
    case "addLine":
      return { ...state, lines: { ...state.lines, [cmd.line.id]: cmd.line } };
    case "deleteLine": {
      const { [cmd.id]: _, ...rest } = state.lines;
      return { ...state, lines: rest };
    }
    case "moveLine": {
      const line = state.lines[cmd.id];
      if (!line) return state;
      return {
        ...state,
        lines: { ...state.lines, [cmd.id]: { ...line, to: cmd.to } },
      };
    }
    case "setColour": {
      const line = state.lines[cmd.id];
      if (!line) return state;
      return {
        ...state,
        lines: {
          ...state.lines,
          [cmd.id]: { ...line, colour: cmd.colour },
        },
      };
    }
    default:
      return assertNever(cmd);
  }
}

function inverse(cmd: Command): Command {
  switch (cmd.kind) {
    case "addLine":
      return { kind: "deleteLine", id: cmd.line.id, snapshot: cmd.line };
    case "deleteLine":
      return { kind: "addLine", line: cmd.snapshot };
    case "moveLine":
      return {
        kind: "moveLine",
        id: cmd.id,
        to: cmd.prev,
        prev: cmd.to,
      };
    case "setColour":
      return {
        kind: "setColour",
        id: cmd.id,
        colour: cmd.prev,
        prev: cmd.colour,
      };
    default:
      return assertNever(cmd);
  }
}
Enter fullscreen mode Exit fullscreen mode

The inverse function is the part that pays for the discriminated union. Every command kind has an obvious mirror. addLine is undone by deleteLine carrying the same line as its snapshot. deleteLine is undone by addLine carrying the snapshot. moveLine swaps to and prev. setColour swaps colour and prev. Symmetric, which is what you want. Applying a command then its inverse should leave the state untouched, and the type signature makes it easy to verify that property in a test.

The default: return assertNever(cmd) is the load-bearing line. Add a fifth command variant to the union and tsc prints the unhandled-discriminant error in both functions, pointing at the exact line that needs a new case. The compiler enforces what ICommand could not.

The History Stack, Redo Stack, and Dispatch

The runtime around the two pure functions is small. The history stack holds commands that have been applied; the redo stack holds commands that have been undone but could be re-applied. dispatch pushes onto history and clears redo. undo pops history, applies the inverse, and pushes onto redo. redo reverses that: pops redo, applies the original, pushes onto history.

type Editor = {
  state: State;
  history: Command[];
  redoStack: Command[];
};

function dispatch(editor: Editor, cmd: Command): Editor {
  return {
    state: apply(editor.state, cmd),
    history: [...editor.history, cmd],
    redoStack: [],
  };
}

function undo(editor: Editor): Editor {
  const last = editor.history[editor.history.length - 1];
  if (!last) return editor;
  return {
    state: apply(editor.state, inverse(last)),
    history: editor.history.slice(0, -1),
    redoStack: [...editor.redoStack, last],
  };
}

function redo(editor: Editor): Editor {
  const next = editor.redoStack[editor.redoStack.length - 1];
  if (!next) return editor;
  return {
    state: apply(editor.state, next),
    history: [...editor.history, next],
    redoStack: editor.redoStack.slice(0, -1),
  };
}
Enter fullscreen mode Exit fullscreen mode

That is the whole engine. The state model is immutable, every transition is pure, and the editor is a value you can pass around and store. In a React or Solid app you put the Editor in a signal or a reducer; the dispatch returns the new editor and the framework re-renders. On a Bun server doing collaborative editing over WebSockets, the editor lives on the server and you broadcast each command after dispatch succeeds.

The load-bearing invariant: dispatch clears the redo stack. The moment you take a new action after an undo, the redo branch is dead. This matches what users expect from Photoshop and VS Code, and it's the one detail people miss when they roll undo themselves. Without the clear, redo silently re-applies stale actions on top of new ones and the state desyncs.

A line count: types (12), apply (28), inverse (24), the editor functions (24). Around 90 lines for a single-user undo engine, before the coalescing layer in the next section.

Coalescing: Typing Letters Should Be One Undo Step

Naive undo treats every keystroke as its own history entry. The user types hello, presses Cmd+Z, expects the word to disappear, instead watches it backspace one letter at a time. After thirty seconds they stop using your app.

The fix is coalescing. Two commands of the same kind, on the same target, within a short window, merge into one history entry. The UX rule from native editors is roughly: same kind, same target, within ~500ms, merge. Otherwise, don't.

Widen the union with two text commands, and extend apply/inverse to handle them. Command stays the single source of truth, so the coalescing layer flows through the same type without casts.

type Command =
  | { kind: "addLine"; line: Line }
  | { kind: "deleteLine"; id: Id; snapshot: Line }
  | { kind: "moveLine"; id: Id; to: Point; prev: Point }
  | { kind: "setColour"; id: Id; colour: Colour; prev: Colour }
  | { kind: "insertText"; id: Id; offset: number; text: string }
  | { kind: "deleteText"; id: Id; offset: number; text: string };

// inside apply:
case "insertText": {
  const cur = state.texts[cmd.id] ?? "";
  const next = cur.slice(0, cmd.offset) + cmd.text + cur.slice(cmd.offset);
  return { ...state, texts: { ...state.texts, [cmd.id]: next } };
}
case "deleteText": {
  const cur = state.texts[cmd.id] ?? "";
  const next = cur.slice(0, cmd.offset) + cur.slice(cmd.offset + cmd.text.length);
  return { ...state, texts: { ...state.texts, [cmd.id]: next } };
}

// inside inverse:
case "insertText":
  return { kind: "deleteText", id: cmd.id, offset: cmd.offset, text: cmd.text };
case "deleteText":
  return { kind: "insertText", id: cmd.id, offset: cmd.offset, text: cmd.text };
Enter fullscreen mode Exit fullscreen mode

The assertNever lines fail to compile until both cases are added in both functions, which is the point.

Now the coalesce function. Same Command type throughout, no casts:

function canCoalesce(
  a: Command,
  b: Command,
  aAt: number,
  bAt: number,
): boolean {
  if (a.kind !== b.kind) return false;
  if (bAt - aAt > 500) return false;
  switch (a.kind) {
    case "insertText":
      return (
        b.kind === "insertText" &&
        a.id === b.id &&
        a.offset + a.text.length === b.offset
      );
    case "setColour":
      return b.kind === "setColour" && a.id === b.id;
    case "moveLine":
      return b.kind === "moveLine" && a.id === b.id;
    default:
      return false;
  }
}

function coalesce(a: Command, b: Command): Command {
  if (a.kind === "insertText" && b.kind === "insertText") {
    return { ...a, text: a.text + b.text };
  }
  if (a.kind === "setColour" && b.kind === "setColour") {
    return { ...a, colour: b.colour };
  }
  if (a.kind === "moveLine" && b.kind === "moveLine") {
    return { ...a, to: b.to };
  }
  return b;
}
Enter fullscreen mode Exit fullscreen mode

Two specifics worth pausing on. insertText coalesces only when consecutive — the offset of b must equal the offset of a plus the length of a.text. Typing hello at offset 0 produces five inserts at offsets 0, 1, 2, 3, 4, all consecutive, which fold into one insertText with text: "hello". Clicking elsewhere and typing again breaks the chain because the offsets no longer line up, and that produces a fresh history entry. This matches what VS Code and most native editors do.

setColour and moveLine coalesce more loosely — drag a colour slider for two seconds and the user expects one undo step, not two hundred. The merge keeps the original prev and the latest target, which is exactly what inverse needs.

dispatch then becomes:

type CoalescingEditor = Editor & { lastAt: number };

function dispatchCoalesced(
  editor: CoalescingEditor,
  cmd: Command,
  now: number,
): CoalescingEditor {
  const last = editor.history[editor.history.length - 1];
  if (last && canCoalesce(last, cmd, editor.lastAt, now)) {
    const merged = coalesce(last, cmd);
    return {
      state: apply(editor.state, cmd),
      history: [...editor.history.slice(0, -1), merged],
      redoStack: [],
      lastAt: now,
    };
  }
  return { ...dispatch(editor, cmd), lastAt: now };
}
Enter fullscreen mode Exit fullscreen mode

No as casts. Editor.history: Command[] already covers every variant, and the unified Command union flows cleanly through apply, inverse, canCoalesce, and coalesce. The compiler is doing what the post promised.

You pass now from the call site (Date.now() in the browser, the server clock in a backend). Coalescing is a UX feature, not a correctness one, so a slightly off clock produces marginally more or fewer merges. The state remains valid in either case.

The Branded Id and Colour

A small detour worth taking, because this is the kind of thing the type system does for free in TS once you stop fighting it. The Id type started life as string. Two minutes later you write a function that takes lineId: Id and userId: Id, and the checker happily lets you pass them in either order, because Id is a bare string.

Branded types fix this by faking nominal typing in a structural type system:

type Brand<T, B> = T & { readonly __brand: B };
type LineId = Brand<string, "LineId">;
type UserId = Brand<string, "UserId">;

function asLineId(s: string): LineId {
  return s as LineId;
}
Enter fullscreen mode Exit fullscreen mode

asLineId is the only place a raw string crosses into the brand. Everywhere else, LineId and UserId cannot be confused. This is overkill for a 90-line example, but for a real editor where commands flow through a network and persist to disk, branding the IDs catches a category of bugs that no amount of testing finds.

The same trick works for Colour. The template literal type `#${string}` rejects "red" and "rgb(255,0,0)". The brand could go further (accept only ^#[0-9a-fA-F]{6}$) at the cost of a small validator function on the boundary.

Where the 90-Line Engine Stops Working

The thing this design quietly assumes is that there is one writer. One user, one tab, one editor. Every command is applied in the order it was dispatched, against a state that nobody else has touched.

The moment a second writer appears, the assumption breaks. A collaborator joins the same document in another tab. Two users issue concurrent commands. The local editor applies its own first, then receives the remote one. The remote editor does the reverse. Both must converge on the same state. With raw command dispatch, they will not. Two insertText commands at the same offset, applied in opposite orders on two clients, produce two different results.

This is where you stop and reach for Yjs, Automerge, or a hand-rolled OT layer. The collaborative-editing problem has a long literature. Operational transformation goes back to Ellis and Gibbs in 1989, and CRDTs (the family Yjs belongs to) had late-2000s precursors and were formalized in 2011 by Shapiro et al. as the conflict-free alternative.

The choice between OT and CRDT for a 2026 codebase, in one sentence each. OT transforms incoming operations against concurrent ones so they land at the right place. Used by Google Docs, Etherpad, and ShareDB. Server-mediated, mature, and brittle to implement; the transformation functions for arbitrary command sets are non-trivial to prove correct. CRDT structures the data so concurrent operations always converge regardless of order. Used by Yjs, Automerge, and Liveblocks. Peer-to-peer or server-mediated, requires no central authority, but the data structures are more memory-hungry and the API surface is less natural than command dispatch. Figma sits between the two: their engineering blog says they evaluated OT, rejected it, and shipped a hybrid that uses "the best parts of OT and CRDT."

Yjs is the practical default for TypeScript apps in 2026. It sits at v13.6.x as of April 2026, has working bindings for ProseMirror, Tiptap, Slate, and Monaco, and integrates with Hocuspocus for server-mediated persistence. The model is Y.Doc containing Y.Array, Y.Map, and Y.Text types: you mutate them as if they were ordinary collections, and the library handles the merge semantics under the hood.

If you build a single-user editor, the discriminated-union engine in this post is the right tool: small, predictable, every line under your control. The day you need real-time collaboration, you replace the state and apply parts with a Y.Doc and let Yjs own the merge, while keeping the command pattern at the UX layer for things Yjs does not model: tool selection, viewport, transient UI state. The two layers compose.

Existing Libraries Worth Knowing

Three TS libraries cover this design space, in order of complexity:

  • immer (~7M weekly downloads on npm as of April 2026) gives you immutable state with mutation-style syntax. It does not include undo/redo by itself, but ships patches, the inverse of every mutation, automatically generated. You stack the patches and apply inverses. Less explicit than a discriminated-union command set, more ergonomic when commands map cleanly to mutations.
  • zundo wraps Zustand with temporal middleware: a transparent history of state snapshots. Suited to small apps where the state is small enough to clone every transition. Memory grows linearly with history length, which is fine until it isn't.
  • redux plus redux-undo is the older school answer. It works the same way as the engine in this post, with more ceremony around the action/reducer split and slightly less clean exhaustiveness checking.

The reason to roll your own is precisely the reason you read this post: 90 lines, every type explicit, every transition documented in the union, no dependency. Once the editor outgrows that, immer or zundo are good upgrade paths that preserve the shape.

The next undo button you wire up, write the command type first and let the engine fall out of it.


If this was useful

The Command pattern as described here lives or dies on the discriminated-union machinery underneath it: the kind field, the exhaustive switch, the assertNever seatbelt, the branded Id. The TypeScript Type System spends real time on those, with a chapter that walks from literal types to template literals to branded primitives and the conditional-type tricks that make libraries like ts-pattern and Yjs's bindings possible to write at all.

If you are coming from JVM languages, the discriminated-union Command pattern is the TypeScript answer to Kotlin's sealed classes and Java's records-with-pattern-matching — Kotlin and Java to TypeScript makes that bridge. If you are coming from PHP 8+, PHP to TypeScript covers the same ground from the other side. If you are shipping a real editor or collaborative app, TypeScript in Production covers the build and library-authoring concerns the type system itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)