Factory Method in TypeScript: Generic Factories

The factory method pattern solves a fundamental problem: decoupling object creation from the code that uses those objects. But in TypeScript, basic factories often sacrifice type safety for...

Key Insights

  • Generic factories combine TypeScript’s type system with the factory method pattern to create reusable, type-safe object creation mechanisms that eliminate runtime type errors while reducing boilerplate code.
  • Constructor type patterns using new (...args: any[]) => T enable dynamic instantiation while preserving full type information, allowing factories to create instances of classes passed as parameters.
  • Registry-based generic factories provide a powerful pattern for plugin architectures and extensible systems, mapping string keys to typed constructors with compile-time safety guarantees.

Introduction to Generic Factories

The factory method pattern solves a fundamental problem: decoupling object creation from the code that uses those objects. But in TypeScript, basic factories often sacrifice type safety for flexibility. Generic factories fix this by leveraging TypeScript’s type system to maintain full type information throughout the creation process.

Consider the difference between a non-generic and generic approach:

// Without generics - loses type information
interface Product {
  name: string;
}

class BasicFactory {
  create(type: string): Product {
    // Returns base type, caller loses specific type info
    if (type === 'widget') return new Widget();
    if (type === 'gadget') return new Gadget();
    throw new Error('Unknown type');
  }
}

// With generics - preserves type information
class GenericFactory<T extends Product> {
  constructor(private ctor: new () => T) {}
  
  create(): T {
    return new this.ctor();
  }
}

const widgetFactory = new GenericFactory(Widget);
const widget = widgetFactory.create(); // Type is Widget, not just Product

The generic version preserves the specific type through the entire creation chain. Your IDE knows exactly what widget is, and the compiler catches type mismatches at build time rather than runtime.

Building a Basic Generic Factory

Start with a clean interface that defines the factory contract:

interface Factory<T> {
  create(): T;
}

interface ConfigurableFactory<T, C> {
  create(config: C): T;
}

// Abstract base for shared factory logic
abstract class BaseFactory<T> implements Factory<T> {
  abstract create(): T;
  
  createMany(count: number): T[] {
    return Array.from({ length: count }, () => this.create());
  }
}

Type constraints ensure factories only work with appropriate types:

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

// Constrained factory - only works with Identifiable & Timestamped types
class EntityFactory<T extends Identifiable & Timestamped> implements Factory<T> {
  constructor(
    private ctor: new () => T,
    private idGenerator: () => string
  ) {}
  
  create(): T {
    const instance = new this.ctor();
    instance.id = this.idGenerator();
    instance.createdAt = new Date();
    instance.updatedAt = new Date();
    return instance;
  }
}

The extends constraint guarantees that any type T passed to EntityFactory will have id, createdAt, and updatedAt properties. The compiler enforces this at every usage site.

Constructor Type Patterns

TypeScript represents constructors as callable types with the new keyword. This pattern is essential for factories that need to instantiate classes dynamically:

// Constructor type with no arguments
type Constructor<T> = new () => T;

// Constructor type with arguments
type ConstructorWithArgs<T, A extends any[]> = new (...args: A) => T;

// Factory function using constructor types
function createInstance<T>(ctor: Constructor<T>): T {
  return new ctor();
}

// With constructor arguments
function createWithArgs<T, A extends any[]>(
  ctor: ConstructorWithArgs<T, A>,
  ...args: A
): T {
  return new ctor(...args);
}

// Usage
class User {
  constructor(public name: string, public email: string) {}
}

const user = createWithArgs(User, 'Alice', 'alice@example.com');
// TypeScript knows: user is User, and enforces correct constructor args

This pattern shines when building flexible factory functions:

interface ProductConfig {
  name: string;
  price: number;
}

class PhysicalProduct {
  constructor(public config: ProductConfig, public weight: number) {}
}

class DigitalProduct {
  constructor(public config: ProductConfig, public downloadUrl: string) {}
}

function productFactory<T>(
  ctor: new (config: ProductConfig, ...extra: any[]) => T,
  config: ProductConfig,
  ...extra: any[]
): T {
  return new ctor(config, ...extra);
}

const physical = productFactory(
  PhysicalProduct,
  { name: 'Book', price: 29.99 },
  1.5
);

const digital = productFactory(
  DigitalProduct,
  { name: 'Ebook', price: 9.99 },
  'https://download.example.com/book.pdf'
);

Registry-Based Generic Factories

Registry patterns map identifiers to constructors, enabling extensible systems where new types can be registered at runtime:

class FactoryRegistry<BaseType> {
  private registry = new Map<string, Constructor<BaseType>>();
  
  register<T extends BaseType>(key: string, ctor: Constructor<T>): void {
    if (this.registry.has(key)) {
      throw new Error(`Factory already registered for key: ${key}`);
    }
    this.registry.set(key, ctor);
  }
  
  create(key: string): BaseType {
    const ctor = this.registry.get(key);
    if (!ctor) {
      throw new Error(`No factory registered for key: ${key}`);
    }
    return new ctor();
  }
  
  has(key: string): boolean {
    return this.registry.has(key);
  }
  
  keys(): string[] {
    return Array.from(this.registry.keys());
  }
}

// Usage with a plugin system
interface Plugin {
  name: string;
  execute(): void;
}

class LoggingPlugin implements Plugin {
  name = 'logging';
  execute() { console.log('Logging...'); }
}

class CachingPlugin implements Plugin {
  name = 'caching';
  execute() { console.log('Caching...'); }
}

const pluginRegistry = new FactoryRegistry<Plugin>();
pluginRegistry.register('logging', LoggingPlugin);
pluginRegistry.register('caching', CachingPlugin);

const plugin = pluginRegistry.create('logging'); // Type: Plugin
plugin.execute();

For stronger typing on retrieval, use a type map pattern:

interface PluginTypeMap {
  logging: LoggingPlugin;
  caching: CachingPlugin;
}

class TypedFactoryRegistry<TypeMap extends Record<string, any>> {
  private registry = new Map<keyof TypeMap, Constructor<TypeMap[keyof TypeMap]>>();
  
  register<K extends keyof TypeMap>(
    key: K,
    ctor: Constructor<TypeMap[K]>
  ): void {
    this.registry.set(key, ctor);
  }
  
  create<K extends keyof TypeMap>(key: K): TypeMap[K] {
    const ctor = this.registry.get(key);
    if (!ctor) throw new Error(`Not registered: ${String(key)}`);
    return new ctor() as TypeMap[K];
  }
}

const typedRegistry = new TypedFactoryRegistry<PluginTypeMap>();
typedRegistry.register('logging', LoggingPlugin);

const loggingPlugin = typedRegistry.create('logging'); // Type: LoggingPlugin

Generic Factories with Dependency Injection

Integrating factories with dependency injection containers enables lazy instantiation and proper lifecycle management:

type ServiceIdentifier<T> = symbol & { __type?: T };

function createIdentifier<T>(name: string): ServiceIdentifier<T> {
  return Symbol(name) as ServiceIdentifier<T>;
}

class DIContainer {
  private services = new Map<symbol, any>();
  private factories = new Map<symbol, () => any>();
  
  registerSingleton<T>(id: ServiceIdentifier<T>, instance: T): void {
    this.services.set(id, instance);
  }
  
  registerFactory<T>(id: ServiceIdentifier<T>, factory: () => T): void {
    this.factories.set(id, factory);
  }
  
  resolve<T>(id: ServiceIdentifier<T>): T {
    if (this.services.has(id)) {
      return this.services.get(id);
    }
    
    const factory = this.factories.get(id);
    if (factory) {
      return factory();
    }
    
    throw new Error(`Service not registered: ${id.toString()}`);
  }
  
  // Factory method that returns a factory function
  createFactory<T>(id: ServiceIdentifier<T>): Factory<T> {
    return {
      create: () => this.resolve(id)
    };
  }
}

// Usage
interface Logger { log(msg: string): void; }
interface Database { query(sql: string): Promise<any>; }

const LOGGER = createIdentifier<Logger>('Logger');
const DATABASE = createIdentifier<Database>('Database');

const container = new DIContainer();
container.registerFactory(LOGGER, () => ({
  log: (msg) => console.log(`[${new Date().toISOString()}] ${msg}`)
}));

const loggerFactory = container.createFactory(LOGGER);
const logger = loggerFactory.create(); // Fresh instance each time

Advanced Patterns: Conditional and Mapped Types

Conditional types enable factories that infer return types based on input:

interface CreateUserInput { type: 'user'; name: string; }
interface CreateAdminInput { type: 'admin'; name: string; permissions: string[]; }

type EntityInput = CreateUserInput | CreateAdminInput;

class User { constructor(public name: string) {} }
class Admin extends User { constructor(name: string, public permissions: string[]) { super(name); } }

type EntityFromInput<T extends EntityInput> = 
  T extends CreateAdminInput ? Admin :
  T extends CreateUserInput ? User :
  never;

function createEntity<T extends EntityInput>(input: T): EntityFromInput<T> {
  if (input.type === 'admin') {
    return new Admin(input.name, (input as CreateAdminInput).permissions) as EntityFromInput<T>;
  }
  return new User(input.name) as EntityFromInput<T>;
}

const user = createEntity({ type: 'user', name: 'Alice' }); // Type: User
const admin = createEntity({ type: 'admin', name: 'Bob', permissions: ['read', 'write'] }); // Type: Admin

Testing and Best Practices

Mock generic factories by implementing the interface with controlled behavior:

// Implementation
class UserService {
  constructor(private userFactory: Factory<User>) {}
  
  createDefaultUser(): User {
    return this.userFactory.create();
  }
}

// Test
describe('UserService', () => {
  it('should create users via factory', () => {
    const mockUser = new User('Test');
    const mockFactory: Factory<User> = {
      create: jest.fn().mockReturnValue(mockUser)
    };
    
    const service = new UserService(mockFactory);
    const result = service.createDefaultUser();
    
    expect(result).toBe(mockUser);
    expect(mockFactory.create).toHaveBeenCalledTimes(1);
  });
});

Avoid these pitfalls:

  1. Over-abstraction: Don’t create generic factories for types that will never vary. A simple new User() is fine when you only ever create users.

  2. Type erasure awareness: Generic type parameters don’t exist at runtime. You can’t do new T() directly—you must pass constructor references.

  3. Excessive generics: If your factory signature looks like Factory<T extends Base, C extends Config<T>, R extends Result<T, C>>, you’ve probably gone too far.

Generic factories excel when you need type-safe object creation across multiple related types, plugin systems, or when integrating with dependency injection. Use them deliberately where the type safety benefits outweigh the added complexity.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.