js

Mastering Dependency Injection in TypeScript: Build Your Own DI Container

Learn how to build a custom dependency injection container in TypeScript to write cleaner, testable, and maintainable code.

Mastering Dependency Injection in TypeScript: Build Your Own DI Container

I’ve been thinking about how modern applications manage their complexity. When you’re building something substantial, you quickly find that wiring everything together by hand becomes a tangled mess. This is why I wanted to look at dependency injection. It’s not just a feature of big frameworks; it’s a fundamental idea for writing clean, testable, and maintainable code. Today, I’ll walk you through creating your own system to handle this, giving you a clear view of what happens under the hood in tools you might already use.

Why build your own? Because understanding the mechanism demystifies the magic. You’ll gain a practical skill and a deeper appreciation for the design of your applications. Let’s get started.

First, we need to understand the problem. Imagine a UserService that needs a database connection and a logger. A simple approach is to create these dependencies inside the class.

class UserService {
  private db = new Database();
  private logger = new Logger();

  getUser(id: string) {
    this.logger.info(`Fetching user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

This works, but it’s rigid. What if you want to test UserService without a real database? What if you need to switch database providers? The class is tightly coupled to specific implementations. This is where dependency injection helps. Instead of creating its own dependencies, the class receives them from the outside.

class UserService {
  constructor(private db: IDatabase, private logger: ILogger) {}

  getUser(id: string) {
    this.logger.info(`Fetching user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

Now, UserService declares what it needs. Something else is responsible for providing those IDatabase and ILogger instances. This “something else” is often called a container. But how does the container know what to provide, and when?

We need a way to register services and then resolve them when asked. Let’s set up a basic TypeScript project to explore this. Create a new directory and initialize it.

npm init -y
npm install typescript reflect-metadata
npm install --save-dev @types/node

Your tsconfig.json file needs two important settings to enable decorators and metadata reflection, which we’ll use shortly.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2022"
  }
}

With the project ready, let’s define what a “service” is in our container. We need a unique way to identify each service. It could be a class itself, a string name, or a Symbol. We also need to think about the object’s lifetime. Should it be a single instance for the whole application? Should a new one be created every time it’s requested?

// di/types.ts
export type ServiceIdentifier = string | symbol | NewableFunction;

export enum ServiceLifetime {
  Singleton,
  Transient,
  Scoped
}

interface ServiceRegistration {
  identifier: ServiceIdentifier;
  factory: (container: Container) => any;
  lifetime: ServiceLifetime;
}

This is our blueprint. A ServiceRegistration ties an identifier to a function that creates the service (factory), along with rules for its lifetime. Now, how do we tell the container about a class? This is where TypeScript decorators become useful. A decorator can mark a class as available for injection.

// di/decorators.ts
import 'reflect-metadata';

export const INJECTABLE_KEY = Symbol('injectable');

export function Injectable(lifetime: ServiceLifetime = ServiceLifetime.Singleton) {
  return function (target: any) {
    Reflect.defineMetadata(INJECTABLE_KEY, { lifetime }, target);
  };
}

When you decorate a class with @Injectable(), we attach a piece of metadata to it. The container will later read this metadata to know how to manage the class. But a class often has dependencies of its own in its constructor. How do we handle those?

TypeScript’s emitDecoratorMetadata option helps here. When enabled, it automatically saves the types of a class’s constructor parameters. We can read this information to figure out what needs to be injected.

// Using the decorator
@Injectable()
class Logger {
  log(message: string) {
    console.log(`[LOG]: ${message}`);
  }
}

@Injectable()
class UserService {
  constructor(private logger: Logger) {}
}

The metadata for UserService will indicate that its first constructor parameter is of type Logger. Our container can use this to automatically resolve and provide a Logger instance. Have you ever wondered how frameworks know what to inject just from the type? This reflection is a key part of the answer.

Now, let’s build the container’s core. It needs a registry to hold our service registrations and a method to resolve them.

// di/container.ts
export class Container {
  private registry = new Map<ServiceIdentifier, ServiceRegistration>();
  private singletonCache = new Map<ServiceIdentifier, any>();

  register<T>(
    identifier: ServiceIdentifier<T>,
    factory: (container: Container) => T,
    lifetime: ServiceLifetime
  ) {
    this.registry.set(identifier, { identifier, factory, lifetime });
  }

  resolve<T>(identifier: ServiceIdentifier<T>): T {
    const registration = this.registry.get(identifier);
    if (!registration) {
      throw new Error(`Service not found: ${identifier.toString()}`);
    }

    // Handle Singleton lifetime
    if (registration.lifetime === ServiceLifetime.Singleton) {
      if (!this.singletonCache.has(identifier)) {
        const instance = registration.factory(this);
        this.singletonCache.set(identifier, instance);
      }
      return this.singletonCache.get(identifier);
    }

    // Handle Transient lifetime
    return registration.factory(this);
  }
}

The resolve method is the heart of the container. It looks up the registration, checks the lifetime, and either returns a cached singleton or creates a new instance. But our register method is still manual. We can make it smarter by auto-registering classes decorated with @Injectable.

class Container {
  // ... previous code

  autoRegister(constructor: any) {
    const metadata = Reflect.getMetadata(INJECTABLE_KEY, constructor);
    if (!metadata) {
      throw new Error(`Class ${constructor.name} is not decorated with @Injectable`);
    }

    this.register(
      constructor,
      (container) => {
        // Get the types of the constructor parameters
        const paramTypes = Reflect.getMetadata('design:paramtypes', constructor) || [];
        // Resolve each dependency
        const dependencies = paramTypes.map((type: any) => container.resolve(type));
        // Create the instance with its dependencies
        return new constructor(...dependencies);
      },
      metadata.lifetime
    );
  }
}

This method extracts the parameter types and recursively resolves them. This is how constructor injection works. You can now set up your application like this:

const container = new Container();

// Auto-register our classes
container.autoRegister(Logger);
container.autoRegister(UserService);

// Resolve the top-level service
const userService = container.resolve(UserService);
userService.getUser('123'); // It works!

The container creates the Logger and injects it into UserService automatically. But what about interfaces? TypeScript interfaces don’t exist at runtime, so we can’t use them as identifiers. A common pattern is to use abstract classes or string/Symbol tokens.

const LOGGER_TOKEN = Symbol('ILogger');

@Injectable()
class ConsoleLogger implements ILogger {
  log(message: string) { console.log(message); }
}

// Register with a token
container.register(LOGGER_TOKEN, (c) => new ConsoleLogger(), ServiceLifetime.Singleton);

// Inject using the token
class AnotherService {
  constructor(@Inject(LOGGER_TOKEN) private logger: ILogger) {}
}

We’d need an @Inject decorator to override the default type-based resolution and specify a token. This adds another layer of flexibility. Can you see how this allows you to decouple an interface from its concrete implementation?

A real-world container also needs to handle more complex scenarios, like circular dependencies. If ServiceA depends on ServiceB, and ServiceB depends on ServiceA, a naive resolution will crash with a stack overflow. A good container detects this and can throw a clear error or use techniques like property injection to break the cycle.

Building this piece by piece shows that a dependency injection container is essentially a sophisticated map and factory system. It manages the lifecycle of objects and their relationships. When you use one in a web framework, it’s often responsible for creating your controllers and services for each HTTP request, ensuring everything is wired up correctly.

The beauty of creating your own is the clarity it brings. You’re no longer relying on a black box. You understand the registration, the resolution, and the lifetime management. This knowledge helps you use any DI system more effectively, as you grasp the principles behind them.

I encourage you to take this foundation and extend it. Try adding scoped lifetimes for web requests, or a method to create child containers. Experiment with property injection or resolving all instances of a given interface. The concepts you’ve seen here are the building blocks used in many popular tools.

I hope this journey from manual instantiation to a custom container has been helpful. It’s a powerful pattern that promotes better code structure. If you found this explanation useful, please share it with others who might be curious about how their frameworks work. Have you built something similar, or do you have questions about specific parts? Let me know in the comments below.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: dependency injection,typescript,design patterns,clean architecture,software engineering



Similar Posts
Blog Image
Build High-Performance GraphQL APIs: NestJS, Prisma, and Redis Complete Tutorial

Learn to build scalable GraphQL APIs with NestJS, Prisma, and Redis. Master performance optimization, caching strategies, and real-time subscriptions.

Blog Image
Building Event-Driven Microservices with NestJS: Complete Guide to RabbitMQ, MongoDB, and Saga Patterns

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master Saga patterns, error handling & deployment strategies.

Blog Image
Complete Guide to Next.js and Prisma ORM Integration: Build Type-Safe Full-Stack Applications

Learn to integrate Next.js with Prisma ORM for type-safe, full-stack web apps. Complete setup guide with best practices. Build faster today!

Blog Image
How to Integrate Next.js with Prisma ORM: Complete Type-Safe Database Setup Guide

Learn to integrate Next.js with Prisma ORM for type-safe, full-stack React applications. Complete guide to seamless database operations and modern web development.

Blog Image
Building Production-Ready Event-Driven Microservices with NestJS, RabbitMQ, and MongoDB: Complete Tutorial

Learn to build production-ready event-driven microservices using NestJS, RabbitMQ & MongoDB. Master async messaging, error handling & scaling patterns.

Blog Image
Build High-Performance GraphQL APIs: Complete TypeScript, Prisma & Apollo Server Development Guide

Learn to build high-performance GraphQL APIs with TypeScript, Prisma & Apollo Server. Master schema-first development, optimization & production deployment.