Building a Simple Dependency Injection System in TypeScript: The Angular Way
Dependency Injection (DI) is one of the core concepts that makes Angular a powerhouse for scalable and maintainable web applications. But what if you wanted to understand the foundational principles of DI? Or better yet, build your own lightweight DI system in TypeScript? This blog dives deep into crafting such a system while sprinkling in some Angular-like magic.
Check out my YouTube video where I explain the code step-by-step and compare it with Angular’s DI system.
Why Dependency Injection?
Before we dive into code, let’s revisit why DI matters. DI simplifies application development by decoupling components and their dependencies. Instead of components creating their own dependencies, DI frameworks inject the required instances, promoting reusability and testability.
Let’s build a minimal DI system that mimics Angular’s behaviour to grasp the concept better.
The @Injectable
Decorator
In Angular, the @Injectable
decorator marks a class as eligible for DI. We can replicate this functionality using TypeScript’s reflect-metadata
package.
import 'reflect-metadata';
// Custom Injectable Decorator
function Injectable(): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata('injectable', true, target);
};
}
The @Injectable
decorator tags a class with metadata, which can later be validated when registering or resolving dependencies.
A DI Container: The Heart of the System
We’ll use a Map
to store and resolve dependencies:
// DI Container
const providers = new Map();
function provide<T>(token: new (...args: any[]) => T, instance: T): void {
const isInjectable = Reflect.getMetadata('injectable', token);
if (!isInjectable) {
throw new Error(`${token.name} is not marked as @Injectable. Cannot provide this class.`);
}
providers.set(token, instance);
}
function inject<T>(token: new (...args: any[]) => T): T {
const instance = providers.get(token);
if (!instance) {
throw new Error(`No provider found for ${token.name}`);
}
return instance;
}
This DI container validates if a class is marked as injectable before registering it, ensuring only intended classes participate in DI.
Crafting Injectable Services
Let’s create some mock services — an HttpClient
to fetch data and a UserService
to handle user-related logic:
@Injectable()
class HttpClient {
get<T>(url: string): T {
// Mocked data fetching
return null as T;
}
}
@Injectable()
class UserService {
constructor(private httpClient: HttpClient) {}
getUsers(): void {
console.log('Fetching users...');
}
}
// Register Providers
provide(HttpClient, new HttpClient());
provide(UserService, new UserService(inject(HttpClient)));
Notice how the UserService
relies on HttpClient
, and this relationship is resolved by our DI system.
Resolving Dependencies in Components
Finally, let’s create a component that depends on the UserService
:
class UserComponent {
constructor(private userService: UserService) {}
render(): void {
this.userService.getUsers();
}
}
// Instantiate and Use the Component
const userComponent = new UserComponent(inject(UserService));
userComponent.render();
Unlike Angular, our component doesn’t use the @Injectable
decorator, as Angular’s DI system handles component instantiation differently.
Enhancing the DI System
To make this system even more robust, you can:
- Implement scoped providers (e.g., singleton vs. transient).
- Add support for hierarchical injectors.
- Automatically resolve dependencies using constructor parameter metadata.
Conclusion
By building this lightweight DI system, we’ve gained a deeper understanding of the principles behind Angular’s DI. While our system is simple, the core concepts — decorators, metadata, and containers — are foundational to Angular’s powerful DI framework.