Siddhant Deval
Siddhant Deval
frontend5 min read

Polymorphism Over Conditionals: Replacing Giant Switch Statements with Strategies

Sprawling switch(shape.type) statements across your React codebase violate the Open/Closed Principle — adding a new shape type requires changing 12 files simultaneously. Polymorphic interfaces and the Strategy pattern allow you to add new shapes and tools without modifying a single existing line of code.

Polymorphism Over Conditionals: Replacing Giant Switch Statements with Strategies

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. The canvas studio's tool system is the most conditional-heavy part of the codebase. Every pointer event — onPointerDown, onPointerMove, onPointerUp — routes through a switch on activeTool. New tools mean new case branches. Shared sub-behaviors (snapping, grid alignment) get copy-pasted. The switch statement grows into the most-read, most-modified, most-feared file in the project.

Polymorphism eliminates the switch. Each tool is an object that implements a common interface. The canvas component calls activeTool.onPointerDown(event) without knowing whether it is talking to SelectTool, RectTool, or PenTool. Adding a new tool is adding a new class — zero changes to the canvas component, zero branches added, zero existing behavior touched.


1. The Switch Anti-Pattern: Growing Without Bounds

The canvas component at month four of development:

TYPESCRIPT
// ❌ Anti-pattern: monolithic switch — every new tool adds to this handler
function CanvasComponent({ doc }: { doc: CanvasDocument }) {
  const snapshot = useCanvasDocument(doc);
  const [activeTool, setActiveTool] = useState<'select' | 'rect' | 'ellipse' | 'pen' | 'text'>('select');
  const [drawStart, setDrawStart] = useState<Point | null>(null);
  const [isDrawing, setIsDrawing] = useState(false);
  const [penPoints, setPenPoints] = useState<Point[]>([]);

  const handlePointerDown = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
    const p = svgPoint(e);
    switch (activeTool) {
      case 'select':
        const hit = doc.hitTest(p);
        if (hit) doc.selectShape(hit.id);
        else doc.clearSelection();
        break;
      case 'rect':
        setDrawStart(p);
        setIsDrawing(true);
        break;
      case 'ellipse':
        setDrawStart(p);
        setIsDrawing(true);
        break;
      case 'pen':
        setPenPoints([p]);
        setIsDrawing(true);
        break;
      case 'text':
        doc.addShape(TextShape.create(makeShapeId(), p.x, p.y, ''));
        // focus the text input...
        break;
    }
  }, [activeTool, doc]);

  const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
    if (!isDrawing) return;
    const p = svgPoint(e);
    switch (activeTool) {
      case 'rect':
        if (drawStart) doc.updatePreview(makePreviewRect(drawStart, p));
        break;
      case 'ellipse':
        if (drawStart) doc.updatePreview(makePreviewEllipse(drawStart, p));
        break;
      case 'pen':
        setPenPoints(prev => [...prev, p]);
        break;
    }
  }, [activeTool, isDrawing, drawStart, doc]);

  // ...handlePointerUp with another full switch
  // Total: ~200 lines of switch/case, growing with every new tool
}

Four problems compound as tools are added:

  1. Shotgun surgery: adding 'lasso' tool requires editing three switch statements (Down, Move, Up) in one file
  2. State leakage: drawStart, isDrawing, penPoints are all in component state but only relevant to specific tools — they accumulate regardless of which tool is active
  3. Untestable: testing rect-drawing behavior requires mounting the entire canvas component
  4. No reuse: snapping logic is copy-pasted between rect and ellipse cases

2. The ITool Strategy Interface

The Strategy Pattern defines a family of algorithms (tools), encapsulates each one, and makes them interchangeable. The canvas component depends only on the ITool interface — not on any concrete tool:

TYPESCRIPT
// src/domain/tools/ITool.ts
export interface ToolPointerEvent {
  point: Point;          // Canvas-space coordinates (already transformed)
  shiftKey: boolean;
  ctrlKey: boolean;
  altKey: boolean;
  pressure: number;      // Stylus support
}

export interface ITool {
  readonly name: string;
  readonly cursor: string; // CSS cursor value

  /** Called when a tool becomes active */
  activate(doc: CanvasDocument): void;

  /** Called when a tool is deactivated (clean up any preview state) */
  deactivate(doc: CanvasDocument): void;

  onPointerDown(event: ToolPointerEvent, doc: CanvasDocument): void;
  onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void;
  onPointerUp(event: ToolPointerEvent, doc: CanvasDocument): void;

  /** Called on Escape key — most tools cancel their current operation */
  onCancel(doc: CanvasDocument): void;

  /** Optional: keyboard shortcut character that activates this tool */
  readonly shortcutKey?: string;
}

The ToolPointerEvent wrapper converts React's PointerEvent into a canvas-space event — the tools never see React types. CanvasDocument is injected per-event, not stored in the tool — tools are stateless regarding the document (they can hold their own drawing state).


3. Concrete Tool Implementations

3.1 SelectTool

TYPESCRIPT
// src/domain/tools/SelectTool.ts
export class SelectTool implements ITool {
  readonly name = 'select';
  readonly cursor = 'default';
  readonly shortcutKey = 'v';

  #dragStart: Point | null = null;
  #isDragging = false;
  #dragTarget: ShapeId | null = null;

  activate(_doc: CanvasDocument): void {
    this.#dragStart = null;
    this.#isDragging = false;
    this.#dragTarget = null;
  }

  deactivate(doc: CanvasDocument): void {
    doc.clearPreview();
  }

  onPointerDown(event: ToolPointerEvent, doc: CanvasDocument): void {
    const hit = doc.hitTest(event.point);

    if (hit) {
      if (event.shiftKey) {
        doc.addToSelection(hit.id);
      } else {
        doc.selectShape(hit.id);
      }
      this.#dragTarget = hit.id;
      this.#dragStart = event.point;
    } else {
      doc.clearSelection();
      // Start marquee selection
      this.#dragStart = event.point;
      this.#dragTarget = null;
    }
  }

  onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#dragStart) return;

    const dx = event.point.x - this.#dragStart.x;
    const dy = event.point.y - this.#dragStart.y;
    const hasMoved = Math.abs(dx) > 2 || Math.abs(dy) > 2; // 2px movement threshold

    if (!hasMoved) return;

    if (this.#dragTarget) {
      // Move the selected shape(s)
      this.#isDragging = true;
      doc.moveSelection(dx, dy);
      this.#dragStart = event.point; // Update start for incremental delta
    } else {
      // Update marquee selection rectangle
      doc.setPreview({
        type: 'marquee',
        rect: rectFromPoints(this.#dragStart, event.point),
      });
    }
  }

  onPointerUp(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#isDragging && !this.#dragTarget) {
      // Marquee complete — select all shapes inside
      const marqueeRect = this.#dragStart
        ? rectFromPoints(this.#dragStart, event.point)
        : null;
      if (marqueeRect) doc.selectShapesInRect(marqueeRect);
    }

    doc.clearPreview();
    this.#dragStart = null;
    this.#isDragging = false;
    this.#dragTarget = null;
  }

  onCancel(doc: CanvasDocument): void {
    if (this.#isDragging) doc.undoLastMove();
    doc.clearSelection();
    doc.clearPreview();
    this.#dragStart = null;
    this.#isDragging = false;
    this.#dragTarget = null;
  }
}

3.2 RectTool

TYPESCRIPT
// src/domain/tools/RectTool.ts
export class RectTool implements ITool {
  readonly name = 'rect';
  readonly cursor = 'crosshair';
  readonly shortcutKey = 'r';

  #drawStart: Point | null = null;

  activate(_doc: CanvasDocument): void { this.#drawStart = null; }
  deactivate(doc: CanvasDocument): void { doc.clearPreview(); this.#drawStart = null; }

  onPointerDown(event: ToolPointerEvent, _doc: CanvasDocument): void {
    this.#drawStart = event.point;
  }

  onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#drawStart) return;

    const r = rectFromPoints(this.#drawStart, event.point);
    // Shift key: constrain to square
    if (event.shiftKey) {
      const side = Math.max(r.width, r.height);
      doc.setPreview({ type: 'rect-preview', rect: rect(r.x, r.y, side, side) });
    } else {
      doc.setPreview({ type: 'rect-preview', rect: r });
    }
  }

  onPointerUp(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#drawStart) return;

    let r = rectFromPoints(this.#drawStart, event.point);
    if (event.shiftKey) {
      const side = Math.max(r.width, r.height);
      r = rect(r.x, r.y, side, side);
    }

    if (r.width > 2 && r.height > 2) {
      // Only commit shapes larger than 2×2px — ignore accidental clicks
      const shape = new RectShape(makeShapeId(), r.x, r.y, r.width, r.height);
      doc.addShape(shape);
      doc.selectShape(shape.id);
    }

    doc.clearPreview();
    this.#drawStart = null;
  }

  onCancel(doc: CanvasDocument): void {
    doc.clearPreview();
    this.#drawStart = null;
  }
}

3.3 PenTool

TYPESCRIPT
// src/domain/tools/PenTool.ts
export class PenTool implements ITool {
  readonly name = 'pen';
  readonly cursor = 'crosshair';
  readonly shortcutKey = 'p';

  #points: Point[] = [];
  #isDrawing = false;

  activate(_doc: CanvasDocument): void { this.#points = []; this.#isDrawing = false; }
  deactivate(doc: CanvasDocument): void { doc.clearPreview(); this.#points = []; }

  onPointerDown(event: ToolPointerEvent, _doc: CanvasDocument): void {
    this.#isDrawing = true;
    this.#points = [event.point];
  }

  onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#isDrawing) return;
    this.#points.push(event.point);
    doc.setPreview({ type: 'path-preview', points: [...this.#points] });
  }

  onPointerUp(event: ToolPointerEvent, doc: CanvasDocument): void {
    if (!this.#isDrawing || this.#points.length < 2) return;

    const shape = new PathShape(makeShapeId(), [...this.#points]);
    doc.addShape(shape);
    doc.selectShape(shape.id);
    doc.clearPreview();
    this.#points = [];
    this.#isDrawing = false;
  }

  onCancel(doc: CanvasDocument): void {
    doc.clearPreview();
    this.#points = [];
    this.#isDrawing = false;
  }
}

4. The ToolController: Managing the Active Strategy

The ToolController manages which tool is active and delegates events to it:

TYPESCRIPT
// src/domain/tools/ToolController.ts
export class ToolController {
  #tools: Map<string, ITool>;
  #activeTool: ITool;
  #doc: CanvasDocument;

  constructor(doc: CanvasDocument) {
    this.#doc = doc;
    this.#tools = new Map([
      ['select',  new SelectTool()],
      ['rect',    new RectTool()],
      ['ellipse', new EllipseTool()],
      ['pen',     new PenTool()],
    ]);
    this.#activeTool = this.#tools.get('select')!;
    this.#activeTool.activate(doc);
  }

  setTool(name: string): void {
    const next = this.#tools.get(name);
    if (!next) throw new DomainError(`Unknown tool: ${name}`);
    if (next === this.#activeTool) return;

    this.#activeTool.deactivate(this.#doc);
    this.#activeTool = next;
    this.#activeTool.activate(this.#doc);
    this.#doc.emit('toolChanged');
  }

  // Register a third-party tool — OCP: extend without modifying
  registerTool(tool: ITool): void {
    this.#tools.set(tool.name, tool);
  }

  get activeTool(): ITool { return this.#activeTool; }
  get cursor(): string { return this.#activeTool.cursor; }

  // All event delegation — no switch statement
  onPointerDown(event: ToolPointerEvent): void { this.#activeTool.onPointerDown(event, this.#doc); }
  onPointerMove(event: ToolPointerEvent): void { this.#activeTool.onPointerMove(event, this.#doc); }
  onPointerUp(event: ToolPointerEvent): void   { this.#activeTool.onPointerUp(event, this.#doc); }
  onCancel(): void                             { this.#activeTool.onCancel(this.#doc); }

  handleKeyDown(key: string): void {
    // Keyboard shortcut routing — also no switch
    for (const tool of this.#tools.values()) {
      if (tool.shortcutKey === key.toLowerCase()) {
        this.setTool(tool.name);
        return;
      }
    }
    if (key === 'Escape') this.onCancel();
  }
}

5. The Canvas Component: Zero Conditionals

The React component now has no tool-specific logic whatsoever:

TYPESCRIPT
// src/components/Canvas.tsx — zero tool conditionals
export function Canvas({ doc, tools }: { doc: CanvasDocument; tools: ToolController }) {
  const snapshot = useCanvasDocument(doc);

  const toToolEvent = (e: React.PointerEvent<SVGSVGElement>): ToolPointerEvent => ({
    point: svgPoint(e, svgRef.current!),
    shiftKey: e.shiftKey,
    ctrlKey: e.ctrlKey,
    altKey: e.altKey,
    pressure: e.pressure,
  });

  return (
    <svg
      ref={svgRef}
      style={{ cursor: tools.cursor }}
      onPointerDown={e => tools.onPointerDown(toToolEvent(e))}
      onPointerMove={e => tools.onPointerMove(toToolEvent(e))}
      onPointerUp={e => tools.onPointerUp(toToolEvent(e))}
    >
      {snapshot.shapes.map(s => <ShapeRenderer key={s.id} data={s} />)}
      {snapshot.preview && <PreviewRenderer preview={snapshot.preview} />}
    </svg>
  );
}

Adding a fifth tool (LassoTool) requires:

  1. Create LassoTool.ts implementing ITool
  2. Register it: tools.registerTool(new LassoTool())

Zero changes to Canvas.tsx. Zero new conditionals. Zero changes to SelectTool, RectTool, or any existing tool.


6. Testing Tools in Isolation

TYPESCRIPT
// tests/unit/domain/tools/RectTool.test.ts
describe('RectTool', () => {
  let tool: RectTool;
  let doc: CanvasDocument;

  beforeEach(() => {
    tool = new RectTool();
    doc = new CanvasDocument();
    tool.activate(doc);
  });

  it('should create a rect shape on pointer up', () => {
    tool.onPointerDown({ point: point(10, 20), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onPointerMove({ point: point(110, 70), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onPointerUp({ point: point(110, 70), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);

    expect(doc.getSnapshot().shapes).toHaveLength(1);
    expect(doc.getSnapshot().shapes[0].type).toBe('rect');
    expect(doc.getSnapshot().shapes[0].width).toBe(100);
    expect(doc.getSnapshot().shapes[0].height).toBe(50);
  });

  it('should constrain to square when shift is held', () => {
    tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onPointerUp({ point: point(100, 60), shiftKey: true, ctrlKey: false, altKey: false, pressure: 1 }, doc);

    const shape = doc.getSnapshot().shapes[0];
    expect(shape.width).toBe(100);
    expect(shape.height).toBe(100); // Constrained to the larger dimension
  });

  it('should not create a shape on accidental click (< 2px)', () => {
    tool.onPointerDown({ point: point(50, 50), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onPointerUp({ point: point(51, 51), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);

    expect(doc.getSnapshot().shapes).toHaveLength(0); // Too small — not committed
  });

  it('should clear preview on cancel', () => {
    tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onPointerMove({ point: point(100, 100), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
    tool.onCancel(doc);

    expect(doc.getSnapshot().preview).toBeNull();
    expect(doc.getSnapshot().shapes).toHaveLength(0);
  });
});

Zero React, zero component mounting, zero act(). Each test is 5 lines and runs in under 1ms.


Summary

Concept Before (Switch) After (Strategy)
Adding a new tool Edit 3 switch statements in canvas component Add one new class implementing ITool
Tool-specific state Component state (drawStart, penPoints) leaks across all tools Private fields inside each tool class
Testing Mount canvas component with @testing-library/react new RectTool() + call methods directly
Snapping/grid logic Copy-pasted into each switch case Shared utility called by any tool that needs it
OCP compliance Closed? No — every new tool opens the canvas component Closed — register new tool, zero existing changes

What's Next

Part 8 applies all five SOLID principles through the frontend lens — showing how SRP manifests in React component design, OCP in the renderer architecture, LSP in tool substitutability, ISP in snapshot interfaces, and DIP in hook-to-domain wiring.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#OOP#TypeScript#Polymorphism#Strategy Pattern#Design Patterns#SOLID#Open/Closed Principle
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.