Back to all posts
TypeScript Frontend Design Patterns Architecture

The Registry Pattern: Configuration Over Conditionals

September 4, 2026
3 min read
Junior Oliveira

The Registry Pattern solves a problem every extensible system eventually hits: you have one renderer (or handler, or processor) that needs to support many kinds of a thing, and you don’t want it to grow a new if/switch branch every single time someone adds one.

🚀 Going straightforward to the advantages:

Encapsulation of Registration – each type registers itself, in its own file. Nothing central has to be edited to add a new one.

Type-safe resolution – ids and props get inferred from the registration itself, so a typo in an id is a compile error, not a runtime surprise.

Decoupling – the renderer depends only on the registry’s resolve() method, never on any concrete implementation.

Here’s the shape of it:

type Registration<TId extends string, TProps> = {
  readonly id: TId;
  readonly load: () => Promise<{ component: React.ComponentType<TProps> }>;
};

class Registry {
  private entries = new Map<string, Registration<string, unknown>>();

  register(entry: Registration<string, unknown>) {
    if (this.entries.has(entry.id)) {
      throw new Error(`Duplicate id: ${entry.id}`);
    }
    this.entries.set(entry.id, entry);
  }

  resolve(id: string) {
    const entry = this.entries.get(id);
    if (!entry) throw new Error(`Unknown id: ${id}`);
    return entry;
  }

  static from(entries: Registration<string, unknown>[]) {
    const registry = new Registry();
    entries.forEach((entry) => registry.register(entry));
    return registry;
  }
}

// One registration per type, colocated with its own component:
const statTileRegistration: Registration<'stat-tile', StatTileProps> = {
  id: 'stat-tile',
  load: async () => ({ component: (await import('./StatTile')).StatTile })
};

const barChartRegistration: Registration<'bar-chart', BarChartProps> = {
  id: 'bar-chart',
  load: async () => ({ component: (await import('./BarChart')).BarChart })
};

const registry = Registry.from([statTileRegistration, barChartRegistration]);

And the payoff — the one piece of code that renders any registered type:

async function renderItem(id: string) {
  const { component: Component } = await registry.resolve(id).load();
  return Component;
}

renderItem has never heard of StatTile or BarChart. It doesn’t need to. Add a hundred more types and this function doesn’t change — that’s the actual test of whether the pattern is holding up: if adding type #101 ever means editing the renderer itself, the registry has already broken down somewhere.

Note: we use exactly this — one registration per type, a factory that builds the registry once from all of them, and a runtime that only ever calls resolve() — in a real dashboard system to manage dozens of widget types (charts, tables, KPI tiles, maps) without the render layer knowing any of them exist. It also plays nicely with a config file (YAML/JSON) describing each type’s metadata instead of hardcoding it, which is what actually lets non-engineers add new widget definitions without touching code.