DEV Community

Cover image for The Registry Pattern in TypeScript: How I manage paving patterns in a browser app
Christoph Dieck
Christoph Dieck

Posted on

The Registry Pattern in TypeScript: How I manage paving patterns in a browser app

The problem

I'm building PatioPlanner, a browser-based tool that lets people draw paved surfaces, choose a laying pattern (herringbone, running bond, basket weave…), and get exact stone counts. The app ships 13 predefined patterns today. New patterns get added regularly.

The question: how do you structure a growing catalog of pattern and brick variants so the system stays extensible? Because if I had to write one more switch(patternType) statement with 13 cases and counting, something had to change.

If you've ever extended a patio in real life, you know the feeling. You start with a neat plan. Then you add one section. Then another. Before you know it, you're cutting irregular edge pieces at weird angles and wondering why you didn't plan the layout better from the start. Same thing happens in code.

The Registry pattern

A Registry is a map that acts as a lookup service. Objects register themselves, and consumers query the registry instead of importing concrete classes. It's a service locator scoped to a single domain concept.

Here's the generic base class I use:

export class Registry<T extends { id: string }> {
  private readonly items = new Map<string, T>();

  register(item: T): T {
    const existing = this.items.get(item.id);
    if (existing !== undefined) return existing; // idempotent
    this.items.set(item.id, item);
    return item;
  }

  getById(id: string): T | undefined {
    return this.items.get(id);
  }

  getAll(): readonly T[] {
    return [...this.items.values()];
  }

  clear(): void {
    this.items.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

Three things worth noting:

  1. The constraint T extends { id: string } ensures every entry has a stable key. Every item needs a unique identifier. No exceptions.
  2. register() is idempotent. If hot-module reload or test setup re-imports a module, nothing breaks. You get the same result whether you call it once or ten times.
  3. getAll() returns a read-only snapshot. Consumers can iterate but not mutate global state. This keeps things predictable.

Two registries, one shape

PatioPlanner has two domain objects that map nicely onto this: bricks and patterns.

// BrickRegistry.ts
class BrickRegistryManager extends Registry<BaseBrick> {
  getByShape(shape: BaseBrick['shape']): readonly BaseBrick[] {
    return this.getAll().filter((brick) => brick.shape === shape);
  }
}
export const BrickRegistry = new BrickRegistryManager();
Enter fullscreen mode Exit fullscreen mode
// PatternRegistry.ts
class PatternRegistryManager extends Registry<BasePattern> {
  getCompatiblePatternsForBrick(brickId: string): readonly BasePattern[] {
    return this.getAll().filter((p) => p.supportsBrick(brickId));
  }
}
export const PatternRegistry = new PatternRegistryManager();
Enter fullscreen mode Exit fullscreen mode

Each registry extends the generic Registry<T> with domain-specific queries. Bricks can be filtered by shape. Patterns can be filtered by brick compatibility. The UI never needs to know which concrete pattern classes exist. It just asks the registry what's available.

Self-registering modules

Each brick or pattern lives in its own file and registers itself as a side effect:

// patterns/RunningBondPattern.ts
import { PatternRegistry } from '../PatternRegistry';
import { BasePattern } from '../BasePattern';

export class RunningBondPattern extends BasePattern {
  constructor() {
    super('RunningBond', 'Running Bond');
  }

  getCompatibleBrickIds(): readonly string[] {
    return ['standard-rectangular-paver', 'standard-square-paver'];
  }

  calculateFootprints(surface: Surface, brick: BaseBrick): BrickFootprint[] {
    const bounds = this.getSurfaceBounds(surface);
    const footprints: BrickFootprint[] = [];

    let rowIndex = 0;
    for (let y = bounds.minY; y < bounds.maxY + brick.height; y += brick.height) {
      const rowOffset = rowIndex % 2 === 0 ? 0 : brick.width / 2;
      for (let x = bounds.minX - brick.width; x < bounds.maxX + brick.width; x += brick.width) {
        footprints.push({
          patternId: this.id,
          brickId: brick.id,
          x: x + rowOffset, y,
          width: brick.width, height: brick.height,
          rotation: 0,
        });
      }
      rowIndex += 1;
    }
    return footprints;
  }
}

PatternRegistry.register(new RunningBondPattern());
Enter fullscreen mode Exit fullscreen mode

The last line is the key. Importing this file is all it takes to make "Running Bond" available everywhere. No config file to update. No central list to maintain.

Wiring it up

A single barrel file imports all brick and pattern modules, triggering registration:

// lib/brick-patterns/index.ts
export { BrickRegistry } from './BrickRegistry';
export { PatternRegistry } from './PatternRegistry';

// Side-effect imports register built-in entries.
import './bricks/StandardRectangleBrick';
import './bricks/StandardSquareBrick';
import './bricks/HoneycombBrick';
import './patterns/RunningBondPattern';
import './patterns/HerringbonePattern';
import './patterns/BasketWeavePattern';
// ...
Enter fullscreen mode Exit fullscreen mode

Any component that imports from lib/brick-patterns automatically has the full catalog populated. Adding a new pattern is one file plus one import line. That's it.

The contract: BasePattern

Every pattern implements a shared interface through an abstract class:

export abstract class BasePattern {
  constructor(public readonly id: string, public readonly name: string) {}

  abstract getCompatibleBrickIds(): readonly string[];
  abstract calculateFootprints(surface: Surface, brick: BaseBrick): readonly BrickFootprint[];

  supportsBrick(brickId: string): boolean {
    return this.getCompatibleBrickIds().includes(brickId);
  }
}
Enter fullscreen mode Exit fullscreen mode

calculateFootprints is where the math lives. Each pattern gets a surface polygon and a brick, and returns all the placed stone positions. The engine then clips them against the polygon boundary to determine full vs. cut stones.

The base class also provides a shared getSurfaceBounds() helper so concrete patterns don't repeat bounding-box math.

Why this works well

Adding patterns is cheap. Write a class, implement two methods, register it. No routing, no config file, no switch statement to update. One file, self-contained.

Compatibility is bidirectional. A brick knows which patterns it supports. A pattern knows which bricks it tiles. The registry resolves both directions without coupling the two.

The UI stays generic. The pattern picker just calls PatternRegistry.getCompatiblePatternsForBrick(activeBrickId) and renders whatever comes back. It never imports a concrete pattern class.

Testing is straightforward. Each pattern is a pure function: surface in, footprints out. No DOM, no framework dependency. Just input and expected output.

Trade-offs

Nothing is free. Here's what you accept with this approach.

Side-effect imports are implicit. If you forget the import line in the barrel, the pattern silently doesn't exist. TypeScript won't warn you. I accept this because the alternative (a manual array of constructors) scales worse. At 13 patterns it's already tedious to maintain.

Singleton state. The registries are module-level singletons. In a server-rendered or multi-tenant context that could be a problem. For a client-side browser app it's fine. One user, one process, one registry. But watch out in unit tests. State can bleed between suites if you're not careful. That's why the base class exposes clear(). Call PatternRegistry.clear() in an afterEach block and you're good.

Tree-shaking can bite you. The self-registering modules rely on side-effect imports. Modern bundlers (Vite, Webpack, Rollup) can aggressively tree-shake imports that aren't explicitly referenced. If your patterns vanish in production, make sure your bundler respects side effects. Either via "sideEffects": true in package.json or by marking specific files. Everything works in dev. Production is a different story. Classic.

No runtime validation. If two patterns share an id, the second one is silently ignored (idempotent register). A stricter approach might throw, but I found that noisy during development with HMR.

Wrapping up

The Registry pattern is simple. Maybe too simple to write about. But it's exactly that simplicity that makes it hold up as the pattern catalog grows from 3 entries to 13 and beyond. Every new pattern is self-contained, testable, and discoverable without touching any existing code.

If you're building something with a growing catalog of strategies or plugins, a typed registry with self-registering modules is a pattern worth reaching for. It's the kind of foundation that stays solid even when you keep building on top of it.

Check out PatioPlanner at patioplanner.app if you want to see it in action.

Over to you

I'd love to hear where you've used (or avoided) the Registry pattern in your own projects. Plugin systems? Theme engines? Payment providers? Maybe you've found a better alternative for the same problem.

Drop a comment:

  1. What domain did you apply it to?
  2. Did you go with self-registering side-effect imports, or did you prefer an explicit list?
  3. Any gotchas you ran into that I didn't mention?

Looking forward to comparing notes.

Top comments (0)