🚀AdvancedExpert Patterns

Dependency Injection Patterns in TypeScript

Implement type-safe dependency injection using interfaces, factory functions, and manual containers - without decorators or runtime metadata.

Overview

**Dependency injection (DI)** decouples components from their dependencies, making code easier to test and reconfigure. TypeScript enables powerful DI patterns without any runtime overhead. There are three common approaches: 1. **Constructor injection with interfaces**: the classic OOP approach. Dependencies are declared as interface types in the constructor. Any conforming object can be passed in. 2. **Factory functions with typed records**: a functional approach. A "container" is just a record of factories; dependencies are passed explicitly or looked up by key. 3. **Manual container with tokens**: similar to Angular or InversifyJS, but without decorators or `reflect-metadata`. Uses TypeScript Symbol or string keys to register and resolve services. Decorator-based DI (as in NestJS/InversifyJS) requires `experimentalDecorators` and `emitDecoratorMetadata`. This topic focuses on patterns that work with standard TypeScript settings and are free of runtime magic.

Key Points

  • •Interface-based injection: depend on interfaces, not concrete classes
  • •Constructor injection: list dependencies as constructor parameters
  • •Factory functions: `(deps: { logger: Logger; db: DB }) => UserService` - explicit, testable
  • •Service container: a Map from token to factory or singleton instance
  • •Typed tokens: `const TOKEN = Symbol() as InjectionToken<ServiceType>` - type-safe resolve()
  • •DI enables easy testing: swap the real database for an in-memory stub
  • •Without decorators: use a registration function or explicit container construction

Examples

Interface-based constructor injection

// Define service interfaces
interface Logger {
  info(message: string): void;
  error(message: string, error?: unknown): void;
}

interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

interface EmailService {
  sendWelcome(email: string, name: string): Promise<void>;
}

interface User {
  id: string;
  name: string;
  email: string;
}

// Service depends on interfaces, not concrete classes
class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly emailService: EmailService,
    private readonly logger: Logger,
  ) {}

  async registerUser(name: string, email: string): Promise<User> {
    this.logger.info(`Registering user: ${email}`);
    const user: User = { id: crypto.randomUUID(), name, email };
    await this.userRepo.save(user);
    await this.emailService.sendWelcome(email, name);
    this.logger.info(`User registered: ${user.id}`);
    return user;
  }

  async getUser(id: string): Promise<User | null> {
    return this.userRepo.findById(id);
  }
}

// Production implementations
class ConsoleLogger implements Logger {
  info(message: string) { console.log('[INFO]', message); }
  error(message: string, error?: unknown) { console.error('[ERROR]', message, error); }
}

// Testing: swap in stubs easily
class InMemoryUserRepo implements UserRepository {
  private store = new Map<string, User>();
  async findById(id: string) { return this.store.get(id) ?? null; }
  async save(user: User) { this.store.set(user.id, user); }
}

class NoOpEmailService implements EmailService {
  async sendWelcome() { /* no-op for tests */ }
}

// Composition root - wire everything together
const userService = new UserService(
  new InMemoryUserRepo(),
  new NoOpEmailService(),
  new ConsoleLogger(),
);

Factory function DI with explicit dependency records

// Define dependencies as a typed record
interface AppDeps {
  logger: Logger;
  userRepo: UserRepository;
  emailService: EmailService;
}

// Factory function pattern: each service is a function of its deps
function createUserService(deps: Pick<AppDeps, 'logger' | 'userRepo' | 'emailService'>) {
  return {
    async registerUser(name: string, email: string): Promise<User> {
      deps.logger.info(`Registering: ${email}`);
      const user: User = { id: crypto.randomUUID(), name, email };
      await deps.userRepo.save(user);
      await deps.emailService.sendWelcome(email, name);
      return user;
    },

    async getUser(id: string): Promise<User | null> {
      return deps.userRepo.findById(id);
    },
  };
}

function createOrderService(deps: Pick<AppDeps, 'logger'>) {
  return {
    createOrder(userId: string, items: string[]): void {
      deps.logger.info(`Order created for user ${userId}`);
    },
  };
}

// Build the complete dependency graph at startup
function buildContainer(): AppDeps {
  const logger: Logger = new ConsoleLogger();
  const userRepo: UserRepository = new InMemoryUserRepo();
  const emailService: EmailService = new NoOpEmailService();
  return { logger, userRepo, emailService };
}

const deps = buildContainer();
const userSvc   = createUserService(deps);
const orderSvc  = createOrderService(deps);

// Testing: just pass different deps
const testDeps: AppDeps = {
  logger:       new ConsoleLogger(),
  userRepo:     new InMemoryUserRepo(),
  emailService: new NoOpEmailService(),
};
const testUserSvc = createUserService(testDeps);

Type-safe service container with typed tokens

// Type-safe injection token
declare const __type: unique symbol;
type InjectionToken<T> = symbol & { readonly [__type]: T };

function token<T>(description: string): InjectionToken<T> {
  return Symbol(description) as InjectionToken<T>;
}

// Define tokens for each service
const LOGGER_TOKEN        = token<Logger>('Logger');
const USER_REPO_TOKEN     = token<UserRepository>('UserRepository');
const EMAIL_SERVICE_TOKEN = token<EmailService>('EmailService');

// Type-safe container
class Container {
  private registry = new Map<symbol, () => unknown>();

  register<T>(token: InjectionToken<T>, factory: () => T): this {
    this.registry.set(token, factory);
    return this;
  }

  resolve<T>(token: InjectionToken<T>): T {
    const factory = this.registry.get(token);
    if (!factory) throw new Error(`No provider for ${String(token)}`);
    return factory() as T;
  }

  singleton<T>(token: InjectionToken<T>, factory: () => T): this {
    let instance: T | undefined;
    return this.register(token, () => {
      if (!instance) instance = factory();
      return instance;
    });
  }
}

// Wire up the container
const container = new Container()
  .singleton(LOGGER_TOKEN,        () => new ConsoleLogger())
  .singleton(USER_REPO_TOKEN,     () => new InMemoryUserRepo())
  .singleton(EMAIL_SERVICE_TOKEN, () => new NoOpEmailService());

// Type-safe resolve - TypeScript knows the type from the token
const logger: Logger            = container.resolve(LOGGER_TOKEN);
const userRepo: UserRepository  = container.resolve(USER_REPO_TOKEN);
const emailSvc: EmailService    = container.resolve(EMAIL_SERVICE_TOKEN);

// Build UserService from resolved deps
const userService2 = new UserService(userRepo, emailSvc, logger);

Common Mistakes

  • Depending on concrete classes instead of interfaces: `constructor(private db: PostgresDatabase)` - this makes testing hard and creates tight coupling. Always depend on the interface: `constructor(private db: Database)`
  • Mixing business logic with infrastructure in constructors: the constructor should accept dependencies, not create them. `new UserService()` that internally creates a `new Database()` is not DI - it is just a class that happens to use composition
  • Creating services inside loops or request handlers: singletons should be created once at application startup. Creating a new database connection per request leaks resources
  • Using `any` when typing container resolve: the typed token pattern ensures `resolve(LOGGER_TOKEN)` returns `Logger`, not `any`. Always use typed tokens

Note

For most applications, explicit constructor injection + a manually assembled "composition root" (the place where you wire all services together) is the right approach. Full DI containers (InversifyJS, tsyringe) add complexity that only pays off in very large apps with many services that need to be resolved dynamically. Start simple and only reach for a container if you genuinely feel the pain of manual wiring.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What is the main benefit of constructor injection (passing dependencies as constructor parameters) over having a class create its own dependencies internally?