Right, so you need a place to store things by a key and then get them back later. Your first thought is, “I know! Map<string, Something>.” And for a lot of cases, that’s fine. But what if Something isn’t an instance, but a class? And what if you don’t just want to retrieve it, but you want to instantiate it later, in a type-safe way, without resorting to as AnyClass hacks that’ll bite you later? Welcome to the Registry Pattern. It’s essentially a type-safe, fancy-pants factory that uses a map under the hood.

Think of it like a vending machine. You press button “A1” (the key), and out pops a perfectly constructed, new can of sugary disappointment (the instance). The machine (the registry) knows how to vend all the different types of drinks it contains. Our job is to build that machine without it ever accidentally giving you a bag of chips when you asked for a soda.

The Basic, Naïve (and Broken) Approach

Let’s start with the obvious, and why it’s terrible. You might be tempted to do this:

class Registry {
  private map = new Map<string, any>();

  register(key: string, constructor: any) {
    this.map.set(key, constructor);
  }

  getInstance(key: string) {
    const Constructor = this.map.get(key);
    if (!Constructor) {
      throw new Error(`Nothing registered for ${key}`);
    }
    return new Constructor(); // Yikes!
  }
}

This is a nightmare. You’ve lost all type information. What does getInstance return? any. That’s like handing someone a loaded gun and saying, “I’m pretty sure the safety’s on… maybe?” You’ll have no idea what you’re getting back, and your TypeScript compiler, your best friend, has just given up and gone home.

Building a Type-Safe Generic Registry

The solution is to make the registry generic on the base type that all registerable classes must share. This is the contract every item in our vending machine must adhere to.

// First, define the base type all registerable things must extend.
type Constructor<T> = new (...args: any[]) => T;

class Registry<T> {
  private map = new Map<string, Constructor<T>>();

  register(key: string, constructor: Constructor<T>) {
    if (this.map.has(key)) {
      // This is a design choice. Should we overwrite? Throw? Up to you.
      throw new Error(`Key "${key}" is already registered.`);
    }
    this.map.set(key, constructor);
  }

  getInstance(key: string): T {
    const Constructor = this.map.get(key);
    if (!Constructor) {
      throw new Error(`Nothing registered for key: "${key}"`);
    }
    return new Constructor();
  }

  // A handy getter to see all available keys
  get availableKeys(): string[] {
    return Array.from(this.map.keys());
  }
}

Now we’re talking. The Registry<T> class only accepts constructors that return a type T. When you get an instance, it’s guaranteed to be T. Let’s use it. Imagine we have a base Plugin interface.

interface Plugin {
  name: string;
  initialize(): void;
}

// Our registry will only accept constructors that produce Plugins
const pluginRegistry = new Registry<Plugin>();

// Define some concrete plugins
class AnalyticsPlugin implements Plugin {
  name = 'Analytics';
  initialize() { console.log('Analytics loaded!'); }
}

class GreeterPlugin implements Plugin {
  name = 'Greeter';
  initialize() { console.log('Hello, world!'); }
}

// Now register them
pluginRegistry.register('analytics', AnalyticsPlugin);
pluginRegistry.register('greeter', GreeterPlugin);

// And use them completely type-safely
const greeter = pluginRegistry.getInstance('greeter'); // Type is Plugin
greeter.initialize(); // "Hello, world!"
console.log(greeter.name); // "Greeter"

This is already miles ahead. The type of greeter is Plugin, not any. Your editor will autocomplete .name and .initialize(). Life is good.

The Curse of the Constructor Signature

Here’s the first rough edge, the thing the designers of your classes didn’t think about. What if your constructors need arguments? Our simple new Constructor() call falls apart spectacularly.

Our Registry assumes a zero-argument constructor, which is a massive and often incorrect assumption. This is the pattern’s Achilles’ heel. The solution is to make the instantiation logic configurable. Instead of hard-coding new Constructor(), we’ll let the user provide a factory function.

class AdvancedRegistry<T> {
  private map = new Map<string, () => T>(); // Now storing a factory, not a constructor

  register(key: string, factory: () => T) {
    this.map.set(key, factory);
  }

  registerClass(key: string, constructor: Constructor<T>) {
    // Provide a convenience method for registering classes with no args
    this.register(key, () => new constructor());
  }

  getInstance(key: string): T {
    const factory = this.map.get(key);
    if (!factory) {
      throw new Error(`Nothing registered for key: "${key}"`);
    }
    return factory();
  }
}

Now you have total control.

const advancedRegistry = new AdvancedRegistry<Plugin>();

// Register a class with a no-arg constructor the easy way
advancedRegistry.registerClass('greeter', GreeterPlugin);

// Register a class that needs dependencies injected
class DatabasePlugin implements Plugin {
  constructor(private connectionString: string) {}
  name = 'Database';
  initialize() { console.log(`Connected to ${this.connectionString}`); }
}

advancedRegistry.register('database', () => {
  const connectionString = process.env.DB_CONNECTION!; // Grab from config
  return new DatabasePlugin(connectionString);
});

// You can even register a singleton instance directly
const mySingletonPlugin = new GreeterPlugin();
advancedRegistry.register('singleton-greeter', () => mySingletonPlugin);

This is the robust way to do it. The registry’s job is now purely to map keys to factory functions, which is a much more flexible and powerful concept. It acknowledges that instantiation is often messy and requires access to other parts of your application state.

Best Practices and Pitfalls

  • Key Management: Use a constants file or an enum for your keys. Typos like pluginRegistry.getInstance('analtyics') are runtime errors that TypeScript can’t catch. getInstance(PluginKeys.ANALYTICS) is far safer.
  • Singletons: The registry itself is often a prime candidate for being a singleton. You don’t want multiple registries floating around with different things registered in them.
  • Circular Dependencies: Be wary of requiring the registry itself within a factory function. This can create circular dependency hell. Pass dependencies explicitly into the factory if you can.
  • Testing: This pattern is a dream for testing. You can easily register mock implementations for any key in your tests, swapping out the real DatabasePlugin for a MockDatabasePlugin without touching any other code.

The registry pattern moves the “how do I create this” problem to a single, well-defined location. It’s the difference between having wiring strewn across your entire codebase and having a single, labeled control panel. It’s not without its quirks, but once you set it up correctly, it’s one of those patterns you’ll wonder how you ever lived without.