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.
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.
A mixin is a function that takes a base class as input and returns a new class that extends the base with additional behavior:
type Constructor<T = {}> = new (...args: any[]) => T;
function Animatable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
#animating = false;
#animationDuration = 300;
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; }
};
}
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; }
protected assertNotLocked(operation: string): void {
if (this.#locked) throw new DomainError(`Cannot ${operation} a locked shape`);
}
};
}
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:
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; }
}
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);
this.#width = w;
this.#height = h;
}
move(dx: number, dy: number): void {
this.assertNotLocked('move');
super.move(dx, dy);
}
resize(w: number, h: number): void {
this.assertNotLocked('resize');
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,
isLocked: this.isLocked,
isAnimating: this.isAnimating,
};
}
}
class EllipseShape extends Animatable(Lockable(BaseShape)) {
#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.
The TypeScript compiler correctly infers the combined type of the mixin chain:
const shape = new RectShape(makeShapeId(), 0, 0, 100, 50);
shape.move(10, 10);
shape.lock();
shape.startAnimation(500);
shape.setCornerRadius(8);
shape.isLocked;
shape.cornerRadius;
All methods are visible and correctly typed. No any assertions needed.
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.
export interface IShape {
readonly id: ShapeId;
readonly isGroup: boolean;
move(dx: number, dy: number): void;
getBounds(): Rect;
hitTest(point: Point): boolean;
readonly isLocked: boolean;
readonly isVisible: boolean;
lock(): void;
unlock(): void;
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:
function renderShapes(shapes: IShape[], ctx: CanvasRenderingContext2D): void {
for (const shape of shapes) {
if (!shape.isVisible) continue;
renderShape(shape, ctx);
}
}
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);
}
addChild(shape: IShape): void {
this.assertNotLocked('add child to');
if (this.#children.has(shape.id)) return;
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; }
readonly isGroup = true as const;
move(dx: number, dy: number): void {
this.assertNotLocked('move');
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 {
return Array.from(this.#children.values()).some(child => child.hitTest(p));
}
lock(): void {
super.lock();
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.
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:
function deepHitTest(shapes: IShape[], p: Point): IShape | null {
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i];
if (!shape.hitTest(p)) continue;
if (shape.isGroup) {
const group = shape as GroupShape;
const childHit = deepHitTest(group.children as IShape[], p);
if (childHit) return childHit;
}
return shape;
}
return null;
}
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.
The canvas studio's CanvasDocument is the Aggregate Root (from the backend series) — the single entry point for all mutations to the shape tree:
export class CanvasDocument {
#shapes: Map<ShapeId, IShape> = new Map();
#renderOrder: ShapeId[] = [];
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()),
};
}
}
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);
expect(bounds.height).toBe(150);
});
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);
});
});
| 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) |
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.