TypeScript Dependency Injection doesn't require heavy decorators. Learn how to use constructor overloading for a type-safe architecture that simplifies testing.
When I first started moving large Node.js services to TypeScript, I reached for the biggest, most complex IoC container I could find. I thought I needed runtime reflection, decorators, and metadata storage to get "proper" dependency injection. I was wrong. Six months later, I spent more time debugging the container’s configuration than actually writing business logic.
If you’re struggling to maintain a Type-Safe Architecture while dealing with messy constructors, you might be over-engineering the solution. Let’s look at how we can leverage native features to handle IoC without the extra weight.
In many frameworks, you see decorators like @Injectable() everywhere. They’re fine for some, but they often hide what’s actually happening. When you rely on reflect-metadata, you’re essentially opting into a runtime layer that TypeScript doesn't natively enforce at compile-time.
Instead, we can lean into TypeScript Constructor Overloading to provide a cleaner API for our services. This allows us to support both production-ready dependency injection and simple, manual instantiation for unit tests without mocking global containers.
Let’s say we have a PaymentService that requires a Database and a Logger. Usually, you'd pass these in the constructor. However, if you want to provide defaults for simple local testing while forcing explicit injection in production, you can use overloading.
TYPESCRIPTinterface Database { query(sql: string): Promise<any>; } interface Logger { log(msg: string): void; } class PaymentService { private db: Database; private logger: Logger; // Overload 1: Default implementation for testing constructor(); // Overload 2: Explicit injection for production/DI containers constructor(db: Database, logger: Logger); // Implementation constructor(db?: Database, logger?: Logger) { this.db = db ?? new MockDatabase(); this.logger = logger ?? new ConsoleLogger(); } }
This approach gives you the best of both worlds. In your test file, you just call new PaymentService(). In your production entry point, you pass the real dependencies. You aren't fighting a container; you're just writing standard classes.
The true goal of Inversion of Control is to decouple your high-level business logic from low-level implementation details. When you start building complex systems, you might find that even this is too much manual work.
I’ve found that managing dependencies manually works perfectly for about 80% of projects. Once the dependency graph grows beyond a certain point—say, 15 or 20 core services—that's when I consider a lightweight container like tsyringe or inversify. But even then, I try to keep the "wiring" logic in one specific folder.
If you are working with modern frameworks, you might already have these patterns in your stack, like when using Next.js AsyncLocalStorage for request-scoped data. The principles remain the same: keep your interfaces strict and your implementations swappable.
One mistake I made early on was putting my entire application configuration into a single DI container. It became a bottleneck. If a developer wanted to test a single function, they had to initialize the entire container, which took roughly 280ms per test execution. That might sound fast, but across a suite of 2,000 tests, it adds up to minutes of wasted developer time.
Instead, prioritize:
Because we aren't using a heavy reflection-based container, our unit tests become much more readable. You don't need to wrap every test in a container-setup function.
TYPESCRIPT// test/payment.test.ts it(CE9178">'should process payment correctly', () => { const mockDb = { query: jest.fn() }; const service = new PaymentService(mockDb, new MockLogger()); // Assertions... });
It’s straightforward. No magic, no hidden metadata, just standard JavaScript object instantiation.
I’m still not convinced that complex IoC containers are necessary for 90% of web projects. We often reach for them because we see them in Java or C# tutorials, but TypeScript Dependency Injection feels different because of how the language handles structural typing.
Next time you’re tempted to pull in a massive dependency injection library, try writing a few constructors with overloading first. You might find that the "simplicity" of the library was actually adding more complexity than it solved. I’m curious to see if this holds up as our codebase continues to scale; for now, keeping it simple has saved me hours of debugging time.
Does constructor overloading affect runtime performance? Not really. TypeScript strips the overload signatures during compilation, leaving only the implementation constructor. The overhead is negligible.
Should I avoid DI containers entirely? Not necessarily. They are useful for large-scale enterprise applications with complex lifecycles, but start without one and add it only when the manual wiring becomes painful.
How does this interact with TypeScript Event Emitters? Usually, you’d inject the Event Emitter into your service just like the Database or Logger. It keeps your event flow testable and predictable.
TypeScript branded types provide a powerful way to enforce domain integrity. Learn how to implement opaque types to prevent bugs and improve code safety.
Read more