Siddhant Deval
Siddhant Deval
frontend5 min read

Composition Over Inheritance: Mixins, Composites & Spatial Tree Aggregates

Deep class inheritance hierarchies collapse under evolving UI requirements. Spatial canvas trees and multi-capability entities must be composed using the Composite pattern and TypeScript mixins — allowing individual shapes and complex groups to be treated uniformly while maintaining independent capability extension.

Composition Over Inheritance: Mixins, Composites & Spatial Tree Aggregates

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But when a canvas studio needs shapes that can be rounded, animated, grouped, and locked simultaneously, the instinct is to model these combinations as an inheritance hierarchy. RoundedRectShape extends RectShape. AnimatedRoundedRectShape extends RoundedRectShape. LockedAnimatedRoundedRectShape extends AnimatedRoundedRectShape. By the time three feature dimensions cross-cut each other, the hierarchy has sixteen leaf classes for four binary properties — an exponential explosion for a linear feature set.

Composition is the structural solution. This article implements the canvas studio's full shape hierarchy using two composition patterns: TypeScript Mixins for cross-cutting capabilities (animation, locking, rounding) and the Composite Pattern for the spatial tree that represents groups containing other groups containing shapes.


1. The Inheritance Explosion Problem

A canvas shape can have these four independent binary capabilities:

  • Rounded corners: corner radius applied to rectangles
  • Animation: property transitions over time
  • Locking: prevents user modification
  • Shadow: drop shadow render effect

With inheritance, every combination requires a class:

BaseShape
  ├── RectShape
  │   ├── RoundedRectShape
  │   │   ├── AnimatedRoundedRectShape
  │   │   │   ├── LockedAnimatedRoundedRectShape
  │   │   │   └── ShadowAnimatedRoundedRectShape
  │   │   └── LockedRoundedRectShape
  │   ├── AnimatedRectShape
  │   └── ShadowRectShape
  ├── EllipseShape
  │   ├── RoundedEllipseShape (doesn't make sense — ellipses ARE round)
  │   ├── AnimatedEllipseShape
  │   └── ...

Four binary capabilities × base shapes = 2⁴ × N = 16 × N classes. Adding a fifth capability (e.g., "opacity mask") doubles the count. This is the Exponential Hierarchy Problem — the core reason "prefer composition over inheritance" exists as a principle.

The secondary problem: tight coupling. If AnimatedRoundedRectShape inherits both animation and rounding from the same ancestor chain, changing the animation mechanism requires auditing every class in the animation branch.


2. TypeScript Mixins: Composing Capabilities

A mixin is a function that takes a base class as input and returns a new class that extends the base with additional behavior:

TYPESCRIPT
// The mixin type signature — accepts any constructor and returns an extended one
type Constructor<T = {}> = new (...args: any[]) => T;

function Animatable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    #animating = false;
    #animationDuration = 300; // ms

    startAnimation(durationMs: number = 300): void {
      this.#animating = true;
      this.#animationDuration = durationMs;
    }

    stopAnimation(): void {
      this.#animating = false;
    }

    get isAnimating(): boolean { return this.#animating; }
    get animationDuration(): number { return this.#animationDuration; }
  };
}
TYPESCRIPT
function Lockable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    #locked = false;

    lock(): void { this.#locked = true; }
    unlock(): void { this.#locked = false; }
    get isLocked(): boolean { return this.#locked; }

    // Guard — call this in any mutation method that should respect locking
    protected assertNotLocked(operation: string): void {
      if (this.#locked) throw new DomainError(`Cannot ${operation} a locked shape`);
    }
  };
}
TYPESCRIPT
function Roundable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    #cornerRadius = 0;

    setCornerRadius(r: number): void {
      if (r < 0) throw new DomainError(`Corner radius must be non-negative: ${r}`);
      this.#cornerRadius = r;
    }

    get cornerRadius(): number { return this.#cornerRadius; }
  };
}

Now combining capabilities is linear, not exponential:

TYPESCRIPT
// Base class
class BaseShape {
  readonly id: ShapeId;
  x: number; y: number;

  constructor(id: ShapeId, x: number, y: number) {
    this.id = id; this.x = x; this.y = y;
  }

  move(dx: number, dy: number): void { this.x += dx; this.y += dy; }
}

// Compose any combination:
const AnimatableLockableBase = Animatable(Lockable(BaseShape));

class RectShape extends Roundable(Animatable(Lockable(BaseShape))) {
  #width: number;
  #height: number;

  constructor(id: ShapeId, x: number, y: number, w: number, h: number) {
    super(id, x, y); // Calls the Lockable → Animatable → BaseShape chain
    this.#width = w;
    this.#height = h;
  }

  move(dx: number, dy: number): void {
    this.assertNotLocked('move'); // From Lockable mixin
    super.move(dx, dy);
  }

  resize(w: number, h: number): void {
    this.assertNotLocked('resize'); // From Lockable mixin
    if (w <= 0 || h <= 0) throw new DomainError('Dimensions must be positive');
    this.#width = w;
    this.#height = h;
  }

  getBounds(): Rect { return rect(this.x, this.y, this.#width, this.#height); }
  hitTest(p: Point): boolean { return containsPoint(this.getBounds(), p); }

  toData(): RectShapeData {
    return {
      type: 'rect',
      id: this.id,
      x: this.x, y: this.y,
      width: this.#width, height: this.#height,
      cornerRadius: this.cornerRadius,     // From Roundable
      isLocked: this.isLocked,             // From Lockable
      isAnimating: this.isAnimating,       // From Animatable
    };
  }
}

class EllipseShape extends Animatable(Lockable(BaseShape)) {
  // Roundable NOT applied — ellipses don't have corner radius
  // Two mixins, same capabilities as RectShape minus rounding
  #rx: number;
  #ry: number;

  constructor(id: ShapeId, cx: number, cy: number, rx: number, ry: number) {
    super(id, cx, cy);
    this.#rx = rx;
    this.#ry = ry;
  }
  // ...
}

RectShape and EllipseShape share Lockable and Animatable behavior without inheriting from a common AnimatableLockableShape base class. Adding a Shadowable mixin touches only the mixin file — zero changes to RectShape or EllipseShape.

2.1 TypeScript Mixin Type Inference

The TypeScript compiler correctly infers the combined type of the mixin chain:

TYPESCRIPT
const shape = new RectShape(makeShapeId(), 0, 0, 100, 50);

shape.move(10, 10);         // ✅ From BaseShape
shape.lock();               // ✅ From Lockable
shape.startAnimation(500);  // ✅ From Animatable
shape.setCornerRadius(8);   // ✅ From Roundable
shape.isLocked;             // ✅ boolean — Lockable getter
shape.cornerRadius;         // ✅ number — Roundable getter

All methods are visible and correctly typed. No any assertions needed.


3. The Composite Pattern: Spatial Tree Aggregates

A canvas studio must support groups — a GroupShape that contains other shapes (including other groups). The group has its own position, and its children's positions are relative to the group's coordinate space. This is the Composite Pattern: a tree where leaf nodes (RectShape, EllipseShape) and composite nodes (GroupShape) share a common interface.

3.1 The IShape Interface

TYPESCRIPT
// src/domain/shapes/IShape.ts
export interface IShape {
  readonly id: ShapeId;
  readonly isGroup: boolean;

  // Spatial
  move(dx: number, dy: number): void;
  getBounds(): Rect;
  hitTest(point: Point): boolean;

  // State
  readonly isLocked: boolean;
  readonly isVisible: boolean;
  lock(): void;
  unlock(): void;

  // Serialization
  toData(): ShapeData;
}

Both leaf shapes (RectShape) and the composite (GroupShape) implement IShape. Code that operates on shapes — the render loop, the hit-tester, the exporter — depends only on IShape:

TYPESCRIPT
// Render loop — operates on IShape[], unaware of GroupShape internals
function renderShapes(shapes: IShape[], ctx: CanvasRenderingContext2D): void {
  for (const shape of shapes) {
    if (!shape.isVisible) continue;
    renderShape(shape, ctx); // Polymorphic dispatch — Part 7 covers this
  }
}

3.2 The GroupShape Implementation

TYPESCRIPT
// src/domain/shapes/GroupShape.ts
export class GroupShape extends Lockable(BaseShape) implements IShape {
  readonly isGroup = true;
  #children: Map<ShapeId, IShape> = new Map();

  constructor(id: GroupId, x: number, y: number) {
    super(id as unknown as ShapeId, x, y);
  }

  // ── Child management ──

  addChild(shape: IShape): void {
    this.assertNotLocked('add child to');
    if (this.#children.has(shape.id)) return; // Idempotent
    this.#children.set(shape.id, shape);
  }

  removeChild(id: ShapeId): void {
    this.assertNotLocked('remove child from');
    if (!this.#children.has(id)) throw new DomainError(`Shape ${id} not found in group ${this.id}`);
    this.#children.delete(id);
  }

  getChild(id: ShapeId): IShape | null {
    return this.#children.get(id) ?? null;
  }

  get children(): ReadonlyArray<IShape> {
    return Array.from(this.#children.values());
  }

  get childCount(): number { return this.#children.size; }

  // ── IShape implementation ──

  readonly isGroup = true as const;

  move(dx: number, dy: number): void {
    this.assertNotLocked('move');
    // Moving a group moves ALL children — the group is the coordinate origin
    super.move(dx, dy);
    for (const child of this.#children.values()) {
      child.move(dx, dy);
    }
  }

  getBounds(): Rect {
    if (this.#children.size === 0) return rect(this.x, this.y, 0, 0);

    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const child of this.#children.values()) {
      const b = child.getBounds();
      minX = Math.min(minX, b.x);
      minY = Math.min(minY, b.y);
      maxX = Math.max(maxX, b.x + b.width);
      maxY = Math.max(maxY, b.y + b.height);
    }
    return rect(minX, minY, maxX - minX, maxY - minY);
  }

  hitTest(p: Point): boolean {
    // Hit on a group = hit on any child
    return Array.from(this.#children.values()).some(child => child.hitTest(p));
  }

  lock(): void {
    super.lock();
    // Locking a group locks all children
    for (const child of this.#children.values()) child.lock();
  }

  unlock(): void {
    super.unlock();
    for (const child of this.#children.values()) child.unlock();
  }

  toData(): GroupShapeData {
    return {
      type: 'group',
      id: this.id,
      x: this.x, y: this.y,
      isLocked: this.isLocked,
      children: Array.from(this.#children.values()).map(c => c.toData()),
    };
  }
}

The group's getBounds() computes the union of all children's bounds — it does not store its own width and height. Moving a group recursively moves all children. Locking a group locks all children. The Composite Pattern makes these recursive operations natural.

3.3 Recursive Operations on the Tree

Any operation that needs to work on the entire tree — exporting, finding shapes at a point, collecting all locked shapes — can use the composite interface uniformly:

TYPESCRIPT
// Deep hit-testing — finds the innermost shape at a point (recursive)
function deepHitTest(shapes: IShape[], p: Point): IShape | null {
  for (let i = shapes.length - 1; i >= 0; i--) { // Reverse order = top-most first
    const shape = shapes[i];
    if (!shape.hitTest(p)) continue;

    if (shape.isGroup) {
      // Recurse into group — find the specific child that was hit
      const group = shape as GroupShape;
      const childHit = deepHitTest(group.children as IShape[], p);
      if (childHit) return childHit;
    }

    return shape; // Leaf shape or group with no child hit
  }
  return null;
}

// Flat export — collects all leaf shapes from any tree depth
function* flattenShapes(shapes: IShape[]): Generator<IShape> {
  for (const shape of shapes) {
    if (shape.isGroup) {
      yield* flattenShapes((shape as GroupShape).children as IShape[]);
    } else {
      yield shape;
    }
  }
}

The hit-test and flatten functions work on any tree depth without knowing about specific shape types — they depend only on IShape and isGroup. Adding a new group type (FrameShape, ComponentShape) requires implementing IShape — zero changes to deepHitTest or flattenShapes.


4. The CanvasDocument as the Aggregate Root

The canvas studio's CanvasDocument is the Aggregate Root (from the backend series) — the single entry point for all mutations to the shape tree:

TYPESCRIPT
// src/domain/canvas/CanvasDocument.ts
export class CanvasDocument {
  #shapes: Map<ShapeId, IShape> = new Map();
  #renderOrder: ShapeId[] = []; // Z-order: index 0 = bottom, last = top

  addShape(shape: IShape): void {
    if (this.#shapes.has(shape.id))
      throw new DomainError(`Shape ${shape.id} already exists in document`);
    this.#shapes.set(shape.id, shape);
    this.#renderOrder.push(shape.id);
    this.emit('shapesChanged');
  }

  removeShape(id: ShapeId): void {
    if (!this.#shapes.has(id)) throw new DomainError(`Shape ${id} not found`);
    this.#shapes.delete(id);
    this.#renderOrder = this.#renderOrder.filter(sid => sid !== id);
    this.emit('shapesChanged');
  }

  groupShapes(ids: ShapeId[], groupId: GroupId): GroupShape {
    if (ids.length < 2) throw new DomainError('A group requires at least 2 shapes');

    const shapesToGroup = ids.map(id => {
      const shape = this.#shapes.get(id);
      if (!shape) throw new DomainError(`Shape ${id} not found`);
      return shape;
    });

    const group = new GroupShape(groupId, 0, 0);
    for (const shape of shapesToGroup) {
      group.addChild(shape);
      this.#shapes.delete(shape.id);
      this.#renderOrder = this.#renderOrder.filter(id => id !== shape.id);
    }

    this.#shapes.set(group.id as unknown as ShapeId, group);
    this.#renderOrder.push(group.id as unknown as ShapeId);
    this.emit('shapesChanged');
    return group;
  }

  getSnapshot(): CanvasSnapshot {
    return {
      shapes: this.#renderOrder
        .map(id => this.#shapes.get(id)!)
        .filter(Boolean)
        .map(s => s.toData()),
    };
  }
}

5. Testing Composition Behavior

TYPESCRIPT
describe('GroupShape', () => {
  it('should move all children when group is moved', () => {
    const group = new GroupShape(makeGroupId() as any, 0, 0);
    const rect  = new RectShape(makeShapeId(), 10, 20, 100, 50);
    const ellipse = new EllipseShape(makeShapeId(), 50, 60, 30, 30);
    group.addChild(rect);
    group.addChild(ellipse);

    group.move(100, 50);

    expect(rect.x).toBe(110); expect(rect.y).toBe(70);
    expect(ellipse.x).toBe(150); expect(ellipse.y).toBe(110);
  });

  it('should compute bounds as union of all children', () => {
    const group = new GroupShape(makeGroupId() as any, 0, 0);
    group.addChild(new RectShape(makeShapeId(), 0, 0, 100, 50));
    group.addChild(new RectShape(makeShapeId(), 200, 100, 50, 50));

    const bounds = group.getBounds();
    expect(bounds.x).toBe(0);
    expect(bounds.y).toBe(0);
    expect(bounds.width).toBe(250);  // 200 + 50
    expect(bounds.height).toBe(150); // 100 + 50
  });

  it('should lock all children when group is locked', () => {
    const group = new GroupShape(makeGroupId() as any, 0, 0);
    const child = new RectShape(makeShapeId(), 0, 0, 100, 50);
    group.addChild(child);
    group.lock();

    expect(group.isLocked).toBe(true);
    expect(child.isLocked).toBe(true);
    expect(() => child.move(10, 10)).toThrow(DomainError);
  });
});

Summary

Pattern Problem Solved Canvas Studio Application
Mixins Exponential inheritance hierarchy for cross-cutting capabilities Lockable, Animatable, Roundable applied compositionally to any shape type
Composite Uniform treatment of leaves and containers in a tree GroupShape implements IShape — hit-test and export work on any depth
IShape interface Decouples operations (render, hit-test, export) from concrete types renderShapes(IShape[]) works without knowing RectShape vs GroupShape
Aggregate Root Single entry point for all tree mutations CanvasDocument.groupShapes() enforces group invariants (min 2 members)

What's Next

Part 7 replaces the switch (activeTool) statement in the canvas event handler with polymorphic dispatch — implementing ITool as a Strategy interface and SelectTool, RectTool, and PenTool as interchangeable strategies that handle pointer events without a single conditional in the canvas component.

Research & Synthesis Note

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

#OOP#TypeScript#Composite Pattern#Mixins#Composition#Design Patterns#Frontend Architecture
Siddhant Deval

Written by Siddhant Deval

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